Add full sync retirement for missing system default tasks
This commit is contained in:
@@ -79,6 +79,7 @@ SERVICE_MASTER_COLUMNS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_TASK_COLUMNS = [
|
DEFAULT_TASK_COLUMNS = [
|
||||||
|
"system_task_id",
|
||||||
"service_code",
|
"service_code",
|
||||||
"sequence_no",
|
"sequence_no",
|
||||||
"task_name",
|
"task_name",
|
||||||
@@ -431,8 +432,8 @@ def build_template(template_type: str) -> bytes:
|
|||||||
elif template_type == "system_default_tasks":
|
elif template_type == "system_default_tasks":
|
||||||
ws.title = "system_default_tasks"
|
ws.title = "system_default_tasks"
|
||||||
ws.append(DEFAULT_TASK_COLUMNS)
|
ws.append(DEFAULT_TASK_COLUMNS)
|
||||||
ws.append(["GST-MONTHLY", 1, "Collect data", "Staff", "TRUE", "FALSE", "TRUE", "Collect sales/purchase data"])
|
ws.append(["", "GST-MONTHLY", 1, "Collect data", "Staff", "TRUE", "FALSE", "", "", "FALSE", "NONE", "FALSE", "FALSE", "NONE", "FALSE", "FALSE", "FALSE", "FALSE", "FALSE", "FALSE", "FALSE", "", "TRUE", "Collect sales/purchase data"])
|
||||||
ws.append(["GST-MONTHLY", 2, "Review and file", "Manager", "TRUE", "TRUE", "TRUE", "Review and file return"])
|
ws.append(["", "GST-MONTHLY", 2, "Review and file", "Manager", "TRUE", "TRUE", "", "", "FALSE", "NONE", "FALSE", "FALSE", "NONE", "FALSE", "FALSE", "FALSE", "FALSE", "FALSE", "FALSE", "FALSE", "", "TRUE", "Review and file return"])
|
||||||
|
|
||||||
elif template_type == "firm_task_templates":
|
elif template_type == "firm_task_templates":
|
||||||
ws.title = "firm_task_templates"
|
ws.title = "firm_task_templates"
|
||||||
@@ -1059,14 +1060,51 @@ def _resolve_import_task_category(db: Session, *, catalogue_id: int, tenant_id:
|
|||||||
return ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=name or None, user_id=user_id)
|
return ensure_task_category(db, catalogue_id=catalogue_id, tenant_id=tenant_id, name=name or None, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes, update_existing: bool = True, expected_service_code: str | None = None) -> dict:
|
def import_system_default_tasks(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
current_user,
|
||||||
|
file_bytes: bytes,
|
||||||
|
update_existing: bool = True,
|
||||||
|
expected_service_code: str | None = None,
|
||||||
|
full_sync: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Import centrally maintained default tasks.
|
||||||
|
|
||||||
|
Normal mode preserves the historical behaviour: existing rows are matched by
|
||||||
|
service + sequence (unless a stable system_task_id is supplied).
|
||||||
|
|
||||||
|
Full Synchronization is intentionally explicit. For each service represented in
|
||||||
|
the workbook it treats the workbook as the complete desired active task list:
|
||||||
|
* existing rows are matched by stable system_task_id first;
|
||||||
|
* older workbooks without IDs fall back to exact task-name matching;
|
||||||
|
* unmatched workbook rows are created;
|
||||||
|
* existing tasks omitted from the workbook are soft-retired (is_active=False);
|
||||||
|
* existing rows are temporarily resequenced so a 120 -> 78 renumber does not
|
||||||
|
collide with the database unique constraint;
|
||||||
|
* history is preserved because system task rows are never physically deleted.
|
||||||
|
|
||||||
|
Retired system defaults are then rolled out through the existing inheritance
|
||||||
|
engine: inherited firm tasks follow automatically, customized firm tasks receive
|
||||||
|
the existing Firm Admin update decision, and only safe pending/unstarted tasks in
|
||||||
|
open engagements are changed.
|
||||||
|
"""
|
||||||
ws, headers = _load_sheet(file_bytes, "system_default_tasks")
|
ws, headers = _load_sheet(file_bytes, "system_default_tasks")
|
||||||
missing = _validate_headers(headers, ["service_code", "sequence_no", "task_name"])
|
missing = _validate_headers(headers, ["service_code", "sequence_no", "task_name"])
|
||||||
if missing:
|
if missing:
|
||||||
return {"created": 0, "updated": 0, "skipped": 0, "errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}]}
|
return {
|
||||||
created = updated = skipped = 0
|
"created": 0, "updated": 0, "retired": 0, "skipped": 0,
|
||||||
|
"errors": [{"row": 1, "message": f"Missing columns: {', '.join(missing)}"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
created = updated = retired = skipped = 0
|
||||||
errors: list[dict] = []
|
errors: list[dict] = []
|
||||||
touched_catalogue_ids: set[int] = set()
|
touched_catalogue_ids: set[int] = set()
|
||||||
|
parsed_rows: list[dict[str, Any]] = []
|
||||||
|
seen_sequences: set[tuple[int, int]] = set()
|
||||||
|
seen_ids: set[int] = set()
|
||||||
|
|
||||||
|
# Phase 1: validate/parse the workbook without changing task rows.
|
||||||
for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
for row_no, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
||||||
if not any(v not in (None, "") for v in row):
|
if not any(v not in (None, "") for v in row):
|
||||||
continue
|
continue
|
||||||
@@ -1076,35 +1114,136 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
|||||||
raise ValueError(f"This import accepts only service code {normalize_code(expected_service_code)}.")
|
raise ValueError(f"This import accepts only service code {normalize_code(expected_service_code)}.")
|
||||||
sequence_no = _int(_cell(row, headers, "sequence_no"), None)
|
sequence_no = _int(_cell(row, headers, "sequence_no"), None)
|
||||||
task_name = _clean(_cell(row, headers, "task_name"))
|
task_name = _clean(_cell(row, headers, "task_name"))
|
||||||
|
system_task_id = _int(_cell(row, headers, "system_task_id"), None)
|
||||||
catalogue = find_service(db, service_code=service_code)
|
catalogue = find_service(db, service_code=service_code)
|
||||||
if not catalogue:
|
if not catalogue:
|
||||||
raise ValueError("Service code not found in service catalogue.")
|
raise ValueError("Service code not found in service catalogue.")
|
||||||
|
if not sequence_no or sequence_no <= 0 or not task_name:
|
||||||
|
raise ValueError("sequence_no must be a positive number and task_name is required.")
|
||||||
|
|
||||||
|
seq_key = (int(catalogue.id), int(sequence_no))
|
||||||
|
if seq_key in seen_sequences:
|
||||||
|
raise ValueError("Duplicate sequence_no for this service in the workbook.")
|
||||||
|
seen_sequences.add(seq_key)
|
||||||
|
|
||||||
|
if system_task_id:
|
||||||
|
if system_task_id in seen_ids:
|
||||||
|
raise ValueError("The same system_task_id appears more than once in the workbook.")
|
||||||
|
seen_ids.add(system_task_id)
|
||||||
|
existing_by_id = db.get(ServiceDefaultTaskTemplate, system_task_id)
|
||||||
|
if existing_by_id is None:
|
||||||
|
raise ValueError(f"system_task_id {system_task_id} does not exist. Leave the ID blank for a new task.")
|
||||||
|
if int(existing_by_id.service_catalogue_id) != int(catalogue.id):
|
||||||
|
raise ValueError(f"system_task_id {system_task_id} belongs to a different service.")
|
||||||
|
|
||||||
|
parsed_rows.append({
|
||||||
|
"row_no": row_no,
|
||||||
|
"row": row,
|
||||||
|
"catalogue": catalogue,
|
||||||
|
"service_code": service_code,
|
||||||
|
"sequence_no": int(sequence_no),
|
||||||
|
"task_name": task_name,
|
||||||
|
"system_task_id": system_task_id,
|
||||||
|
})
|
||||||
touched_catalogue_ids.add(int(catalogue.id))
|
touched_catalogue_ids.add(int(catalogue.id))
|
||||||
if not sequence_no or not task_name:
|
except Exception as exc:
|
||||||
raise ValueError("sequence_no and task_name are required.")
|
errors.append({"row": row_no, "message": str(exc)})
|
||||||
task = db.execute(
|
|
||||||
select(ServiceDefaultTaskTemplate).where(
|
if errors:
|
||||||
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue.id,
|
db.rollback()
|
||||||
ServiceDefaultTaskTemplate.sequence_no == sequence_no,
|
return {"created": 0, "updated": 0, "retired": 0, "skipped": 0, "errors": errors}
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
# Cache existing rows before applying changes. Full sync needs this original set
|
||||||
|
# to know which rows were genuinely omitted from the workbook.
|
||||||
|
existing_by_catalogue: dict[int, list[ServiceDefaultTaskTemplate]] = {}
|
||||||
|
for catalogue_id in sorted(touched_catalogue_ids):
|
||||||
|
existing_by_catalogue[catalogue_id] = list(db.execute(
|
||||||
|
select(ServiceDefaultTaskTemplate)
|
||||||
|
.where(ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id)
|
||||||
|
.order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc())
|
||||||
|
).scalars().all())
|
||||||
|
|
||||||
|
# Full-sync re-numbering can move task 90 to sequence 40 while old task 40 still
|
||||||
|
# exists. Move the original rows to a high temporary range first to avoid the
|
||||||
|
# service+sequence unique constraint during the transaction.
|
||||||
|
if full_sync:
|
||||||
|
for catalogue_id, existing_rows in existing_by_catalogue.items():
|
||||||
|
max_seq = max([int(t.sequence_no or 0) for t in existing_rows] or [0])
|
||||||
|
temporary_base = max(max_seq + 100000, 100000)
|
||||||
|
for offset, task in enumerate(existing_rows, start=1):
|
||||||
|
task.sequence_no = temporary_base + offset
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
matched_existing_ids: dict[int, set[int]] = {cid: set() for cid in touched_catalogue_ids}
|
||||||
|
|
||||||
|
# Phase 2: upsert workbook rows.
|
||||||
|
for item in parsed_rows:
|
||||||
|
row_no = item["row_no"]
|
||||||
|
row = item["row"]
|
||||||
|
catalogue = item["catalogue"]
|
||||||
|
catalogue_id = int(catalogue.id)
|
||||||
|
sequence_no = item["sequence_no"]
|
||||||
|
task_name = item["task_name"]
|
||||||
|
system_task_id = item["system_task_id"]
|
||||||
|
try:
|
||||||
|
task = None
|
||||||
|
if system_task_id:
|
||||||
|
task = db.get(ServiceDefaultTaskTemplate, int(system_task_id))
|
||||||
|
elif full_sync:
|
||||||
|
# Backward-compatible path for pre-ID exports (including the current
|
||||||
|
# reduced Tax Audit workbook): exact task name is safer than sequence
|
||||||
|
# because the whole list may have been resequenced.
|
||||||
|
normalised = task_name.strip().casefold()
|
||||||
|
name_matches = [
|
||||||
|
t for t in existing_by_catalogue.get(catalogue_id, [])
|
||||||
|
if (t.task_name or "").strip().casefold() == normalised
|
||||||
|
and int(t.id) not in matched_existing_ids[catalogue_id]
|
||||||
|
]
|
||||||
|
if len(name_matches) == 1:
|
||||||
|
task = name_matches[0]
|
||||||
|
else:
|
||||||
|
task = db.execute(
|
||||||
|
select(ServiceDefaultTaskTemplate).where(
|
||||||
|
ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id,
|
||||||
|
ServiceDefaultTaskTemplate.sequence_no == sequence_no,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if task and not update_existing:
|
if task and not update_existing:
|
||||||
|
matched_existing_ids[catalogue_id].add(int(task.id))
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if task:
|
if task:
|
||||||
|
matched_existing_ids[catalogue_id].add(int(task.id))
|
||||||
updated += 1
|
updated += 1
|
||||||
else:
|
else:
|
||||||
task = ServiceDefaultTaskTemplate(service_catalogue_id=catalogue.id, sequence_no=sequence_no, task_name=task_name)
|
task = ServiceDefaultTaskTemplate(
|
||||||
|
service_catalogue_id=catalogue_id,
|
||||||
|
sequence_no=sequence_no,
|
||||||
|
task_name=task_name,
|
||||||
|
)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
|
db.flush()
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
|
task.sequence_no = sequence_no
|
||||||
task.task_name = task_name
|
task.task_name = task_name
|
||||||
task.description = _clean(_cell(row, headers, "description")) or None
|
task.description = _clean(_cell(row, headers, "description")) or None
|
||||||
task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None
|
task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None
|
||||||
|
if "eligible_role_names" in headers:
|
||||||
|
task.eligible_role_names = _clean(_cell(row, headers, "eligible_role_names")) or None
|
||||||
task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True)
|
task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True)
|
||||||
task.requires_review = _bool(_cell(row, headers, "requires_review"), False)
|
task.requires_review = _bool(_cell(row, headers, "requires_review"), False)
|
||||||
|
if "normal_review_role" in headers:
|
||||||
|
normal_review_role = (_clean(_cell(row, headers, "normal_review_role")) or "").lower()
|
||||||
|
if normal_review_role and normal_review_role not in {"manager", "partner", "manager_or_partner"}:
|
||||||
|
raise ValueError("normal_review_role must be manager, partner or manager_or_partner.")
|
||||||
|
task.normal_review_role = normal_review_role or None
|
||||||
|
|
||||||
category = _resolve_import_task_category(
|
category = _resolve_import_task_category(
|
||||||
db,
|
db,
|
||||||
catalogue_id=catalogue.id,
|
catalogue_id=catalogue_id,
|
||||||
tenant_id=None,
|
tenant_id=None,
|
||||||
category_code=_clean(_cell(row, headers, "task_category_code")),
|
category_code=_clean(_cell(row, headers, "task_category_code")),
|
||||||
category_name=_clean(_cell(row, headers, "task_category")),
|
category_name=_clean(_cell(row, headers, "task_category")),
|
||||||
@@ -1130,6 +1269,20 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
|||||||
task.is_active = _bool(_cell(row, headers, "is_active"), True)
|
task.is_active = _bool(_cell(row, headers, "is_active"), True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append({"row": row_no, "message": str(exc)})
|
errors.append({"row": row_no, "message": str(exc)})
|
||||||
|
|
||||||
|
# Full Synchronization: anything that existed before this import but was not
|
||||||
|
# represented in the workbook is retired, never hard-deleted. Keep its temporary
|
||||||
|
# high sequence so active workbook rows own the concise 1..N sequence safely.
|
||||||
|
if full_sync and not errors:
|
||||||
|
for catalogue_id, existing_rows in existing_by_catalogue.items():
|
||||||
|
matched = matched_existing_ids.get(catalogue_id, set())
|
||||||
|
for task in existing_rows:
|
||||||
|
if int(task.id) in matched:
|
||||||
|
continue
|
||||||
|
if task.is_active:
|
||||||
|
task.is_active = False
|
||||||
|
retired += 1
|
||||||
|
|
||||||
rollout_summary = {
|
rollout_summary = {
|
||||||
"firms_processed": 0,
|
"firms_processed": 0,
|
||||||
"firms_changed": 0,
|
"firms_changed": 0,
|
||||||
@@ -1137,11 +1290,9 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
|||||||
"engagement_created": 0,
|
"engagement_created": 0,
|
||||||
"engagement_updated_pending": 0,
|
"engagement_updated_pending": 0,
|
||||||
"engagement_deactivated_pending": 0,
|
"engagement_deactivated_pending": 0,
|
||||||
|
"engagement_preserved_history": 0,
|
||||||
}
|
}
|
||||||
if not errors:
|
if not errors:
|
||||||
# Apply the same transaction to every enabled firm and safely refresh only
|
|
||||||
# unstarted tasks in open/unlocked engagements. Customized firm tasks are
|
|
||||||
# never overwritten; they receive an update-available decision instead.
|
|
||||||
from app.modules.services.default_task_sync import sync_system_defaults_to_all_firms
|
from app.modules.services.default_task_sync import sync_system_defaults_to_all_firms
|
||||||
for catalogue_id in sorted(touched_catalogue_ids):
|
for catalogue_id in sorted(touched_catalogue_ids):
|
||||||
rollout = sync_system_defaults_to_all_firms(
|
rollout = sync_system_defaults_to_all_firms(
|
||||||
@@ -1156,13 +1307,17 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes,
|
|||||||
rollout_summary["engagement_created"] += rollout.engagement_created
|
rollout_summary["engagement_created"] += rollout.engagement_created
|
||||||
rollout_summary["engagement_updated_pending"] += rollout.engagement_updated_pending
|
rollout_summary["engagement_updated_pending"] += rollout.engagement_updated_pending
|
||||||
rollout_summary["engagement_deactivated_pending"] += rollout.engagement_deactivated_pending
|
rollout_summary["engagement_deactivated_pending"] += rollout.engagement_deactivated_pending
|
||||||
|
rollout_summary["engagement_preserved_history"] += rollout.engagement_preserved_history
|
||||||
db.commit()
|
db.commit()
|
||||||
else:
|
else:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"created": created if not errors else 0,
|
"created": created if not errors else 0,
|
||||||
"updated": updated if not errors else 0,
|
"updated": updated if not errors else 0,
|
||||||
|
"retired": retired if not errors else 0,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
|
"full_sync": bool(full_sync),
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
**(rollout_summary if not errors else {}),
|
**(rollout_summary if not errors else {}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.modules.services.models import ServiceCatalogue, ServiceDefaultTaskTemp
|
|||||||
|
|
||||||
|
|
||||||
_HEADERS = [
|
_HEADERS = [
|
||||||
|
"system_task_id",
|
||||||
"service_code",
|
"service_code",
|
||||||
"service_name",
|
"service_name",
|
||||||
"category_code",
|
"category_code",
|
||||||
@@ -41,6 +42,7 @@ _HEADERS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
_IMPORT_HEADERS = [
|
_IMPORT_HEADERS = [
|
||||||
|
"system_task_id",
|
||||||
"service_code",
|
"service_code",
|
||||||
"sequence_no",
|
"sequence_no",
|
||||||
"task_name",
|
"task_name",
|
||||||
@@ -168,6 +170,7 @@ def build_default_tasks_export(db: Session, *, catalogue_id: int | None = None)
|
|||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
all_tasks_ws.append([
|
all_tasks_ws.append([
|
||||||
|
task.id,
|
||||||
service.service_code,
|
service.service_code,
|
||||||
service.service_name,
|
service.service_name,
|
||||||
category_code,
|
category_code,
|
||||||
@@ -196,6 +199,7 @@ def build_default_tasks_export(db: Session, *, catalogue_id: int | None = None)
|
|||||||
task.description or "",
|
task.description or "",
|
||||||
])
|
])
|
||||||
import_ws.append([
|
import_ws.append([
|
||||||
|
task.id,
|
||||||
service.service_code,
|
service.service_code,
|
||||||
task.sequence_no,
|
task.sequence_no,
|
||||||
task.task_name,
|
task.task_name,
|
||||||
@@ -252,9 +256,9 @@ def build_default_tasks_export(db: Session, *, catalogue_id: int | None = None)
|
|||||||
("Default task rows exported", total_tasks),
|
("Default task rows exported", total_tasks),
|
||||||
("Active default task rows", active_tasks),
|
("Active default task rows", active_tasks),
|
||||||
("Services without default tasks", services_without_tasks),
|
("Services without default tasks", services_without_tasks),
|
||||||
("Import Ready sheet", "Matches the existing System Default Tasks import column order"),
|
("Import Ready sheet", "Includes stable system_task_id so renaming/resequencing can update the correct task safely"),
|
||||||
("Historical inactive services", "Not included, so merged or inactive services do not create confusion"),
|
("Historical inactive services", "Not included, so merged or inactive services do not create confusion"),
|
||||||
("Important", "Review edited rows before re-importing. Service codes and task sequence numbers control matching and uniqueness."),
|
("Important", "Keep system_task_id unchanged for existing tasks. New rows may leave it blank. Full Synchronization can retire existing tasks omitted from the workbook."),
|
||||||
]
|
]
|
||||||
info_ws.append(["Item", "Value"])
|
info_ws.append(["Item", "Value"])
|
||||||
for item, value in info_rows:
|
for item, value in info_rows:
|
||||||
|
|||||||
@@ -66,12 +66,13 @@
|
|||||||
|
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h3 class="text-sm font-semibold text-slate-900">Import system default tasks</h3>
|
<h3 class="text-sm font-semibold text-slate-900">Import system default tasks</h3>
|
||||||
<p class="mt-1 text-sm text-slate-500">System Admin only. Creates/updates default task templates by service code and sequence no.</p>
|
<p class="mt-1 text-sm text-slate-500">System Admin only. Creates/updates centrally maintained default tasks. Exported workbooks use stable system_task_id; Full Synchronization can retire old tasks omitted from the workbook.</p>
|
||||||
<a href="/services/bulk-imports/templates/system-default-tasks.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
<a href="/services/bulk-imports/templates/system-default-tasks.xlsx" class="mt-4 inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download Template</a>
|
||||||
<form method="post" action="/services/bulk-imports/system-default-tasks" enctype="multipart/form-data" class="mt-4 space-y-3">
|
<form method="post" action="/services/bulk-imports/system-default-tasks" enctype="multipart/form-data" class="mt-4 space-y-3" onsubmit="if (this.full_sync.checked) return confirm('Full Synchronization will retire existing system-default tasks that are missing from the workbook for each service represented in this file. Historical work will be preserved. Continue?');">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
<input type="file" name="file" accept=".xlsx" required class="block w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update existing sequence rows</label>
|
<label class="inline-flex items-center gap-2 text-sm text-slate-700"><input type="checkbox" name="update_existing" checked> Update existing tasks</label>
|
||||||
|
<label class="inline-flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"><input type="checkbox" name="full_sync" class="mt-1"><span><strong>Full Synchronization</strong> — for each service present in this workbook, retire existing system-default tasks that are missing from the workbook. Use this when replacing a complete checklist, such as reducing Tax Audit from 120 to 78 tasks.</span></label>
|
||||||
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Default Tasks</button></div>
|
<div><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Upload Default Tasks</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
<a href="{{ back_url }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
<a href="{{ back_url }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-4">
|
<div class="grid gap-4 md:grid-cols-5">
|
||||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Created</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.created }}</div></div>
|
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Created</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.created }}</div></div>
|
||||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Updated</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.updated }}</div></div>
|
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Updated</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.updated }}</div></div>
|
||||||
|
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Retired</div><div class="mt-1 text-2xl font-semibold text-amber-700">{{ result.retired|default(0) }}</div></div>
|
||||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Skipped</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.skipped }}</div></div>
|
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Skipped</div><div class="mt-1 text-2xl font-semibold text-slate-900">{{ result.skipped }}</div></div>
|
||||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Errors</div><div class="mt-1 text-2xl font-semibold {% if result.errors %}text-rose-700{% else %}text-emerald-700{% endif %}">{{ result.errors|length }}</div></div>
|
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs text-slate-500">Errors</div><div class="mt-1 text-2xl font-semibold {% if result.errors %}text-rose-700{% else %}text-emerald-700{% endif %}">{{ result.errors|length }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-800">Import completed successfully.</div>
|
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-800">Import completed successfully.{% if result.full_sync %} Full Synchronization was applied; omitted existing system tasks were retired rather than hard-deleted.{% endif %}</div>
|
||||||
|
{% if result.full_sync %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900"><strong>History-safe synchronization:</strong> retired system tasks remain in the database for audit/history. Inherited firm tasks are synchronized automatically; customized firm tasks use the existing Firm Admin decision flow; safe pending/unstarted open-engagement tasks are updated/deactivated while started/completed/reviewed history is preserved.</div>{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
<div class="flex flex-wrap gap-2"><a href="/services/catalogue/{{ service.id }}/defaults/export" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Export Tasks</a><a href="/services/catalogue/{{ service.id }}/defaults/duplicates" class="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100">Duplicate Review</a><a href="/services/catalogue/{{ service.id }}/task-categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div>
|
<div class="flex flex-wrap gap-2"><a href="/services/catalogue/{{ service.id }}/defaults/export" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100">Export Tasks</a><a href="/services/catalogue/{{ service.id }}/defaults/duplicates" class="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100">Duplicate Review</a><a href="/services/catalogue/{{ service.id }}/task-categories" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Task Categories</a><a href="/services/defaults" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a><a href="/services/catalogue/{{ service.id }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Catalogue Detail</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if request.query_params.get('imported') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated.</div>{% endif %}
|
{% if request.query_params.get('imported') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Excel import completed: {{ request.query_params.get('created','0') }} created, {{ request.query_params.get('updated','0') }} updated, {{ request.query_params.get('retired','0') }} retired.{% if request.query_params.get('full_sync') == '1' %} Full Synchronization was applied.{% endif %}</div>{% endif %}
|
||||||
{% if request.query_params.get('import_error') %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Excel import failed validation. No partial update was committed. Review the workbook and try again.</div>{% endif %}
|
{% if request.query_params.get('import_error') %}<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Excel import failed validation. No partial update was committed. Review the workbook and try again.</div>{% endif %}
|
||||||
<section class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
<section class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><h3 class="text-sm font-semibold text-emerald-950">Export · Edit · Re-import This Service</h3><p class="mt-1 text-xs text-emerald-800">Export this service's current tasks, edit the Import Ready sheet, then re-import. Matching remains service code + sequence number.</p></div>
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><h3 class="text-sm font-semibold text-emerald-950">Export · Edit · Re-import This Service</h3><p class="mt-1 text-xs text-emerald-800">Export this service's current tasks, edit the Import Ready sheet, then re-import. Existing exports now carry a stable system_task_id so renaming and resequencing update the correct task.</p></div>
|
||||||
<form method="post" action="/services/catalogue/{{ service.id }}/defaults/import" enctype="multipart/form-data" class="flex flex-wrap items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="file" name="file" accept=".xlsx" required class="max-w-xs rounded-xl border border-emerald-200 bg-white px-3 py-2 text-xs"><button class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Import Updated Excel</button></form></div>
|
<form method="post" action="/services/catalogue/{{ service.id }}/defaults/import" enctype="multipart/form-data" class="flex flex-wrap items-center gap-2" onsubmit="if (this.full_sync.checked) return confirm('Full Synchronization will retire existing system-default tasks for this service that are missing from the workbook. Historical work will be preserved. Continue?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="file" name="file" accept=".xlsx" required class="max-w-xs rounded-xl border border-emerald-200 bg-white px-3 py-2 text-xs"><label class="inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-900"><input type="checkbox" name="full_sync"> Full Synchronization (retire missing tasks)</label><button class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Import Updated Excel</button></form></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -2085,7 +2085,7 @@ def default_tasks_service_export(request: Request, catalogue_id: int):
|
|||||||
finally: db.close()
|
finally: db.close()
|
||||||
|
|
||||||
@router.post('/catalogue/{catalogue_id}/defaults/import')
|
@router.post('/catalogue/{catalogue_id}/defaults/import')
|
||||||
def default_tasks_service_import(request: Request, catalogue_id: int, file: UploadFile = File(...), csrf_token: str = Form(...)):
|
def default_tasks_service_import(request: Request, catalogue_id: int, file: UploadFile = File(...), full_sync: str | None = Form(None), csrf_token: str = Form(...)):
|
||||||
validate_csrf(request, csrf_token); db = CommonSessionLocal()
|
validate_csrf(request, csrf_token); db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
user=get_current_user(request, db=db)
|
user=get_current_user(request, db=db)
|
||||||
@@ -2094,9 +2094,9 @@ def default_tasks_service_import(request: Request, catalogue_id: int, file: Uplo
|
|||||||
if not _is_system_admin(db,user): return _redirect_denied()
|
if not _is_system_admin(db,user): return _redirect_denied()
|
||||||
catalogue=get_catalogue(db,catalogue_id)
|
catalogue=get_catalogue(db,catalogue_id)
|
||||||
if not catalogue: return RedirectResponse(url='/services/defaults',status_code=303)
|
if not catalogue: return RedirectResponse(url='/services/defaults',status_code=303)
|
||||||
result=import_system_default_tasks(db,current_user=user,file_bytes=file.file.read(),update_existing=True,expected_service_code=catalogue.service_code)
|
result=import_system_default_tasks(db,current_user=user,file_bytes=file.file.read(),update_existing=True,expected_service_code=catalogue.service_code,full_sync=full_sync is not None)
|
||||||
if result.get('errors'): return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults?import_error=1',status_code=303)
|
if result.get('errors'): return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults?import_error=1',status_code=303)
|
||||||
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults?imported=1&created={result.get("created",0)}&updated={result.get("updated",0)}',status_code=303)
|
return RedirectResponse(url=f'/services/catalogue/{catalogue_id}/defaults?imported=1&created={result.get("created",0)}&updated={result.get("updated",0)}&retired={result.get("retired",0)}&full_sync={1 if result.get("full_sync") else 0}',status_code=303)
|
||||||
finally: db.close()
|
finally: db.close()
|
||||||
|
|
||||||
@router.get('/templates/{catalogue_id}/export')
|
@router.get('/templates/{catalogue_id}/export')
|
||||||
@@ -2516,7 +2516,7 @@ def upload_service_master(request: Request, file: UploadFile = File(...), update
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-imports/system-default-tasks")
|
@router.post("/bulk-imports/system-default-tasks")
|
||||||
def upload_system_default_tasks(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(None), csrf_token: str = Form(...)):
|
def upload_system_default_tasks(request: Request, file: UploadFile = File(...), update_existing: str | None = Form(None), full_sync: str | None = Form(None), csrf_token: str = Form(...)):
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -2529,7 +2529,7 @@ def upload_system_default_tasks(request: Request, file: UploadFile = File(...),
|
|||||||
require_permission(db, user, "service_tasks.create")
|
require_permission(db, user, "service_tasks.create")
|
||||||
except Exception:
|
except Exception:
|
||||||
return _redirect_denied()
|
return _redirect_denied()
|
||||||
result = import_system_default_tasks(db, current_user=user, file_bytes=file.file.read(), update_existing=update_existing is not None)
|
result = import_system_default_tasks(db, current_user=user, file_bytes=file.file.read(), update_existing=update_existing is not None, full_sync=full_sync is not None)
|
||||||
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="System Default Tasks Import Result", result=result, back_url="/services/bulk-imports")
|
return _render(request, "modules/services/templates/services/bulk_imports/result.html", db, user, title="System Default Tasks Import Result", result=result, back_url="/services/bulk-imports")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user