- Implement tests to ensure the case tree contains parent and child branches while preventing cycles. - Verify that the timeline UI utilizes tree filters and cursor paging. - Confirm that the relation tree is displayed as a separate tab with appropriate search filters and inline module content. Enhance time tracking with prepaid card usage alerts - Create a migration to track alerts for prepaid card usage at 80% and 100%. - Implement triggers to generate cases when usage thresholds are reached, ensuring proper notifications and case management. Introduce audit logging for time entry edits - Modify the sync log to include time entry updates in the audit stream without altering existing records. Establish tracking for vTiger case imports and exports - Create tables to track cases imported from vTiger into Hub and exports from Hub to vTiger, ensuring idempotency and proper reference management.
2239 lines
98 KiB
Python
2239 lines
98 KiB
Python
"""HTTP API for the manual migration centre."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import hashlib
|
|
import math
|
|
from html.parser import HTMLParser
|
|
from datetime import date, datetime, time as datetime_time, timedelta
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from psycopg2.extras import Json, RealDictCursor
|
|
|
|
from app.core.auth_dependencies import require_any_permission, require_permission
|
|
from app.core.config import settings
|
|
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
|
from app.modules.migration_center.backend.service import (
|
|
MUTABLE_LOCK_STATES,
|
|
audit,
|
|
create_session,
|
|
ensure_writable,
|
|
json_value,
|
|
match_item,
|
|
preflight_token,
|
|
report_csv,
|
|
user_id,
|
|
verify_preflight,
|
|
snapshot_hash,
|
|
attach_economic_snapshot,
|
|
EconomicSnapshotRepository,
|
|
subscription_like_item_sql,
|
|
refresh_subscription_relevance,
|
|
)
|
|
from app.services.simplycrm_service import SimplyCRMService
|
|
from app.services.vtiger_service import get_vtiger_service
|
|
|
|
router = APIRouter()
|
|
|
|
EXCLUDED_INVOICE_LINE_SQL = """
|
|
NOT (
|
|
source_system='economic' AND (
|
|
LOWER(COALESCE(product_name,'')) LIKE '%%gebyr%%'
|
|
OR LOWER(COALESCE(product_name,'')) LIKE '%%fragt%%'
|
|
OR LOWER(COALESCE(product_name,'')) LIKE '%%porto%%'
|
|
)
|
|
)
|
|
"""
|
|
|
|
|
|
class SessionCreate(BaseModel):
|
|
name: str = Field(min_length=2, max_length=160)
|
|
|
|
|
|
class SessionUpdate(BaseModel):
|
|
status: Optional[str] = None
|
|
read_only: Optional[bool] = None
|
|
|
|
|
|
class CustomerCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=255)
|
|
cvr_number: Optional[str] = None
|
|
email: Optional[str] = None
|
|
customer_no: Optional[str] = None
|
|
|
|
|
|
class CaseCreate(BaseModel):
|
|
title: str = Field(min_length=1, max_length=255)
|
|
description: str = ""
|
|
|
|
|
|
class LinkPayload(BaseModel):
|
|
hub_id: int
|
|
|
|
|
|
class ReasonPayload(BaseModel):
|
|
reason: str = Field(min_length=2, max_length=2000)
|
|
|
|
|
|
class NotePayload(BaseModel):
|
|
note: str = Field(max_length=5000)
|
|
|
|
|
|
class CreatePayload(BaseModel):
|
|
preflight_token: str
|
|
idempotency_key: str = Field(min_length=8, max_length=120)
|
|
|
|
|
|
class VtigerCaseImportRequest(BaseModel):
|
|
ticket_number: str = Field(min_length=3, max_length=80)
|
|
|
|
@field_validator("ticket_number")
|
|
@classmethod
|
|
def validate_ticket_number(cls, value: str) -> str:
|
|
normalized = str(value or "").strip().upper()
|
|
if not re.fullmatch(r"CC[0-9]{1,12}", normalized):
|
|
raise ValueError("Indtast et sagsnummer i formatet CC12345")
|
|
return normalized
|
|
|
|
|
|
class HubCaseExportRequest(BaseModel):
|
|
sag_id: int = Field(gt=0)
|
|
|
|
|
|
class HubCaseExportConfirmRequest(HubCaseExportRequest):
|
|
source_hash: str = Field(min_length=64, max_length=64)
|
|
|
|
|
|
def _vtiger_reference_id(value: Any) -> Optional[str]:
|
|
if isinstance(value, dict):
|
|
value = value.get("value") or value.get("id")
|
|
value = str(value or "").strip()
|
|
return value if re.fullmatch(r"[A-Za-z0-9_-]+x[0-9]+", value) else None
|
|
|
|
|
|
def _vtiger_case_number(raw: Dict[str, Any]) -> str:
|
|
for key in ("ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber"):
|
|
value = str(raw.get(key) or "").strip()
|
|
if value:
|
|
return value.upper()
|
|
return ""
|
|
|
|
|
|
def _case_status_from_vtiger(value: Any) -> str:
|
|
status = _vtiger_text(value).lower()
|
|
if status in {"closed", "lukket", "afsluttet", "done", "cancelled", "canceled"}:
|
|
return "Lukket"
|
|
if status in {"resolved", "løst", "lost"}:
|
|
return "Løst"
|
|
if status in {"assigned", "tildelt"}:
|
|
return "Tildelt"
|
|
if status in {"in progress", "in_progress", "igangværende", "open", "åben", "reopened"}:
|
|
return "Igangværende" if status not in {"open", "åben"} else "Ny"
|
|
if status in {"waiting", "waiting_customer", "waiting_internal", "on_hold", "afventer"}:
|
|
return "Afventer"
|
|
return "Ny"
|
|
|
|
|
|
def _case_priority_from_vtiger(value: Any) -> str:
|
|
priority = _vtiger_text(value).lower()
|
|
if priority in {"urgent", "critical", "akut"}:
|
|
return "urgent"
|
|
if priority in {"high", "høj"}:
|
|
return "high"
|
|
if priority in {"low", "lav"}:
|
|
return "low"
|
|
return "normal"
|
|
|
|
|
|
def _vtiger_text(value: Any) -> str:
|
|
if isinstance(value, dict):
|
|
value = value.get("label") or value.get("value") or ""
|
|
return str(value or "").strip()
|
|
|
|
|
|
class _VTigerPlainTextParser(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__(convert_charrefs=True)
|
|
self.parts = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag.lower() in {"br", "p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}:
|
|
self.parts.append("\n")
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag.lower() in {"p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}:
|
|
self.parts.append("\n")
|
|
|
|
def handle_data(self, data):
|
|
self.parts.append(data)
|
|
|
|
|
|
def _vtiger_plain_text(value: Any) -> str:
|
|
raw = _vtiger_text(value)
|
|
if not raw:
|
|
return ""
|
|
parser = _VTigerPlainTextParser()
|
|
parser.feed(raw)
|
|
text = re.sub(r"\n[ \t]*\n+", "\n\n", "".join(parser.parts))
|
|
return text.strip()
|
|
|
|
|
|
def _vtiger_timestamp(value: Any) -> Optional[datetime]:
|
|
if not value:
|
|
return None
|
|
text = str(value).strip().replace("T", " ")
|
|
try:
|
|
return datetime.fromisoformat(text[:19])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _timelog_source_id(row: Dict[str, Any]) -> str:
|
|
return str(row.get("id") or "").strip()
|
|
|
|
|
|
def _timelog_description(row: Dict[str, Any], *, legacy: bool = False) -> str:
|
|
# Older imports preferred the generic record name (often "migration").
|
|
keys = ("name", "cf_timelog_description", "description") if legacy else (
|
|
"description", "cf_timelog_description", "name"
|
|
)
|
|
for key in keys:
|
|
value = _vtiger_plain_text(row.get(key))
|
|
if value:
|
|
return value
|
|
return "Importerede vTiger-timer"
|
|
|
|
|
|
async def _resolve_vtiger_assignee_names(timelogs: List[Dict[str, Any]]) -> Dict[str, str]:
|
|
"""Resolve time-entry owners in two batch queries instead of one call per row."""
|
|
refs = sorted({
|
|
ref for row in timelogs
|
|
if (ref := _vtiger_reference_id(row.get("assigned_user_id")))
|
|
})
|
|
if not refs:
|
|
return {}
|
|
id_list = "', '".join(refs)
|
|
vtiger = get_vtiger_service()
|
|
names: Dict[str, str] = {}
|
|
users = await vtiger.query(
|
|
"SELECT id, first_name, last_name, user_name FROM Users "
|
|
f"WHERE id IN ('{id_list}');"
|
|
)
|
|
for row in users or []:
|
|
ref = _vtiger_reference_id(row.get("id"))
|
|
if not ref:
|
|
continue
|
|
full_name = " ".join(
|
|
part for part in (_vtiger_text(row.get("first_name")), _vtiger_text(row.get("last_name"))) if part
|
|
).strip()
|
|
names[ref] = full_name or _vtiger_text(row.get("user_name")) or ref
|
|
groups = await vtiger.query(
|
|
"SELECT id, groupname FROM Groups "
|
|
f"WHERE id IN ('{id_list}');"
|
|
)
|
|
for row in groups or []:
|
|
ref = _vtiger_reference_id(row.get("id"))
|
|
if ref and ref not in names:
|
|
names[ref] = _vtiger_text(row.get("groupname")) or ref
|
|
return names
|
|
|
|
|
|
async def _load_vtiger_case_bundle(ticket_number: str) -> Dict[str, Any]:
|
|
normalized = str(ticket_number or "").strip().upper()
|
|
if not re.fullmatch(r"CC[0-9]{1,12}", normalized):
|
|
raise HTTPException(status_code=400, detail="Indtast et sagsnummer i formatet CC12345")
|
|
vtiger = get_vtiger_service()
|
|
case = None
|
|
for field in ("ticket_no", "ticketnumber", "ticket_number", "case_no"):
|
|
safe_value = normalized.replace("'", "''")
|
|
records = await vtiger.query(f"SELECT * FROM Cases WHERE {field}='{safe_value}' LIMIT 2;")
|
|
if records:
|
|
if len(records) > 1:
|
|
raise HTTPException(status_code=409, detail=f"Flere vTiger-sager matcher {normalized}; overførslen er stoppet")
|
|
case = records[0]
|
|
break
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail=f"Sagen {normalized} blev ikke fundet i vTiger")
|
|
|
|
vtiger_case_id = _vtiger_reference_id(case.get("id"))
|
|
if not vtiger_case_id:
|
|
raise HTTPException(status_code=502, detail="vTiger-sagen mangler et gyldigt post-id")
|
|
actual_number = _vtiger_case_number(case) or normalized
|
|
if actual_number != normalized:
|
|
raise HTTPException(status_code=409, detail=f"vTiger returnerede {actual_number} i stedet for {normalized}; overførslen er stoppet")
|
|
account_id = _vtiger_reference_id(case.get("parent_id") or case.get("account_id"))
|
|
if not account_id:
|
|
raise HTTPException(status_code=409, detail="Sagen har ingen entydig vTiger-kundetilknytning")
|
|
|
|
customer = execute_query_single(
|
|
"SELECT id, name, vtiger_id FROM customers WHERE vtiger_id = %s AND deleted_at IS NULL",
|
|
(account_id,),
|
|
)
|
|
if not customer:
|
|
account_rows = await vtiger.query(f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;")
|
|
account = account_rows[0] if account_rows else {}
|
|
account_name = str(account.get("accountname") or "").strip()
|
|
if account_name:
|
|
matches = execute_query(
|
|
"SELECT id, name, vtiger_id FROM customers WHERE LOWER(TRIM(name)) = LOWER(TRIM(%s)) AND deleted_at IS NULL ORDER BY id LIMIT 2",
|
|
(account_name,),
|
|
) or []
|
|
if len(matches) == 1:
|
|
customer = matches[0]
|
|
if not customer:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Kunden {account_id} er ikke entydigt tilknyttet en Hub-kunde. Link kunden først.",
|
|
)
|
|
|
|
timelogs = await vtiger.query(
|
|
f"SELECT * FROM Timelog WHERE relatedto='{vtiger_case_id}' ORDER BY startedon LIMIT 1001;"
|
|
)
|
|
comments = await vtiger.query(
|
|
f"SELECT * FROM ModComments WHERE related_to='{vtiger_case_id}' ORDER BY createdtime LIMIT 1000;"
|
|
)
|
|
# vTiger installations can ignore filters on reference fields; enforce the
|
|
# relationship locally before any source records are shown or imported.
|
|
timelogs = [row for row in (timelogs or []) if _vtiger_reference_id(row.get("relatedto")) == vtiger_case_id]
|
|
comments = [row for row in (comments or []) if _vtiger_reference_id(row.get("related_to")) == vtiger_case_id]
|
|
if len(timelogs) > 1000:
|
|
raise HTTPException(status_code=409, detail="Sagen har mere end 1.000 tidsregistreringer. Overførslen er stoppet for at undgå en ufuldstændig kopi.")
|
|
assignee_names = await _resolve_vtiger_assignee_names(timelogs)
|
|
contact_id = _vtiger_reference_id(case.get("contact_id") or case.get("contactid"))
|
|
contact = execute_query_single(
|
|
"SELECT id, first_name, last_name FROM contacts WHERE vtiger_id = %s",
|
|
(contact_id,),
|
|
) if contact_id else None
|
|
imported = execute_query_single(
|
|
"SELECT hub_sag_id, vtiger_marked_at FROM vtiger_case_hub_imports WHERE vtiger_case_id = %s",
|
|
(vtiger_case_id,),
|
|
)
|
|
return {
|
|
"case": case,
|
|
"ticket_number": actual_number,
|
|
"vtiger_case_id": vtiger_case_id,
|
|
"account_id": account_id,
|
|
"customer": customer,
|
|
"contact": contact,
|
|
"timelogs": timelogs or [],
|
|
"timelog_assignee_names": assignee_names,
|
|
"comments": comments or [],
|
|
"existing_import": imported,
|
|
}
|
|
|
|
|
|
def _import_vtiger_case_bundle(bundle: Dict[str, Any], current_user: dict) -> Dict[str, Any]:
|
|
"""Create a native Hub case and link source time entries atomically."""
|
|
conn = get_db_connection()
|
|
imported_now = False
|
|
vtiger_case_id = bundle["vtiger_case_id"]
|
|
case = bundle["case"]
|
|
customer = bundle["customer"]
|
|
current_user_id = user_id(current_user)
|
|
imported_time_entries = 0
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute(
|
|
"SELECT hub_sag_id, vtiger_marked_at FROM vtiger_case_hub_imports WHERE vtiger_case_id=%s FOR UPDATE",
|
|
(vtiger_case_id,),
|
|
)
|
|
mapped = cursor.fetchone()
|
|
if mapped:
|
|
sag_id = mapped["hub_sag_id"]
|
|
# Repair legacy imports without changing approval or billing state.
|
|
assignee_names = bundle.get("timelog_assignee_names", {})
|
|
for timelog in bundle.get("timelogs", []):
|
|
source_id = _timelog_source_id(timelog)
|
|
assignee_ref = _vtiger_reference_id(timelog.get("assigned_user_id"))
|
|
old_user_name = _vtiger_text(timelog.get("assigned_user_id"))
|
|
resolved_user_name = assignee_names.get(assignee_ref)
|
|
if not source_id:
|
|
continue
|
|
if assignee_ref and resolved_user_name and resolved_user_name != old_user_name:
|
|
cursor.execute(
|
|
"""UPDATE tmodule_times SET user_name=%s
|
|
WHERE vtiger_id=%s AND sag_id=%s AND user_name=%s""",
|
|
(resolved_user_name, source_id, sag_id, old_user_name),
|
|
)
|
|
old_description = _timelog_description(timelog, legacy=True)
|
|
new_description = _timelog_description(timelog)
|
|
if old_description != new_description:
|
|
cursor.execute(
|
|
"""UPDATE tmodule_times SET description=%s
|
|
WHERE vtiger_id=%s AND sag_id=%s AND description=%s""",
|
|
(new_description, source_id, sag_id, old_description),
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
"""INSERT INTO sag_sager
|
|
(titel, beskrivelse, template_key, type, status, priority, customer_id, created_by_user_id)
|
|
VALUES (%s,%s,'ticket','ticket',%s,%s,%s,%s) RETURNING id""",
|
|
(
|
|
_vtiger_text(case.get("ticket_title") or case.get("subject")) or bundle["ticket_number"],
|
|
_vtiger_text(case.get("description")),
|
|
_case_status_from_vtiger(case.get("status")),
|
|
_case_priority_from_vtiger(case.get("priority")),
|
|
customer["id"], current_user_id,
|
|
),
|
|
)
|
|
sag_id = cursor.fetchone()["id"]
|
|
|
|
contact = bundle.get("contact")
|
|
if contact:
|
|
cursor.execute(
|
|
"INSERT INTO sag_kontakter (sag_id, contact_id, role) VALUES (%s,%s,'Primær kontakt')",
|
|
(sag_id, contact["id"]),
|
|
)
|
|
|
|
cursor.execute(
|
|
"""INSERT INTO tmodule_customers (vtiger_id, name, hub_customer_id, vtiger_data)
|
|
VALUES (%s,%s,%s,%s) ON CONFLICT (vtiger_id) DO UPDATE
|
|
SET hub_customer_id=COALESCE(tmodule_customers.hub_customer_id, EXCLUDED.hub_customer_id)
|
|
RETURNING id""",
|
|
(bundle["account_id"], customer["name"], customer["id"], Json({"id": bundle["account_id"]})),
|
|
)
|
|
tcustomer_id = cursor.fetchone()["id"]
|
|
cursor.execute(
|
|
"""INSERT INTO tmodule_cases
|
|
(vtiger_id, customer_id, title, description, status, priority, module_type, vtiger_data, sync_hash)
|
|
VALUES (%s,%s,%s,%s,%s,%s,'Cases',%s,%s)
|
|
ON CONFLICT (vtiger_id) DO UPDATE SET
|
|
customer_id=EXCLUDED.customer_id, title=EXCLUDED.title,
|
|
description=EXCLUDED.description, status=EXCLUDED.status,
|
|
priority=EXCLUDED.priority, vtiger_data=EXCLUDED.vtiger_data,
|
|
sync_hash=EXCLUDED.sync_hash, last_synced_at=CURRENT_TIMESTAMP
|
|
RETURNING id""",
|
|
(
|
|
vtiger_case_id, tcustomer_id,
|
|
_vtiger_text(case.get("ticket_title") or case.get("subject")) or bundle["ticket_number"],
|
|
_vtiger_text(case.get("description")), _vtiger_text(case.get("status")),
|
|
_vtiger_text(case.get("priority")), Json(case),
|
|
hashlib.sha256(json.dumps(case, sort_keys=True, default=str).encode()).hexdigest(),
|
|
),
|
|
)
|
|
tcase_id = cursor.fetchone()["id"]
|
|
|
|
for timelog in bundle.get("timelogs", []):
|
|
source_id = _timelog_source_id(timelog)
|
|
seconds = _amount(timelog.get("duration"))
|
|
if not source_id or seconds <= 0:
|
|
continue
|
|
hours = (seconds / Decimal("3600")).quantize(Decimal("0.01"))
|
|
if hours <= 0:
|
|
continue
|
|
billable = str(timelog.get("isbillable") or "0") == "1"
|
|
already_billed = any(str(timelog.get(key) or "0") not in {"", "0"} for key in (
|
|
"cf_timelog_invoiced", "billed_via_thehub_id", "cf_timelog_billedviathehubid",
|
|
"cf_timelog_rounduptimespent",
|
|
))
|
|
start = _vtiger_timestamp(timelog.get("startedon") or timelog.get("createdtime"))
|
|
data_hash = hashlib.sha256(json.dumps(timelog, sort_keys=True, default=str).encode()).hexdigest()
|
|
cursor.execute(
|
|
"""INSERT INTO tmodule_times
|
|
(vtiger_id, case_id, customer_id, sag_id, description, original_hours,
|
|
worked_date, user_name, billable, vtiger_data, sync_hash, status,
|
|
approved_hours, billing_method, faktisk_tid_min, fakturerbar_tid_min,
|
|
start_tid, entry_status, kilde, entry_type, work_type)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'api','ukendt','support')
|
|
ON CONFLICT (vtiger_id) DO UPDATE SET sag_id=EXCLUDED.sag_id
|
|
WHERE tmodule_times.case_id=EXCLUDED.case_id
|
|
AND (tmodule_times.sag_id IS NULL OR tmodule_times.sag_id=EXCLUDED.sag_id)
|
|
RETURNING id""",
|
|
(
|
|
source_id, tcase_id, tcustomer_id, sag_id,
|
|
_timelog_description(timelog),
|
|
hours, start.date() if start else _safe_date(timelog.get("startedon")),
|
|
bundle.get("timelog_assignee_names", {}).get(
|
|
_vtiger_reference_id(timelog.get("assigned_user_id")),
|
|
_vtiger_text(timelog.get("assigned_user_id")),
|
|
), billable, Json(timelog), data_hash,
|
|
"billed" if already_billed else "pending", hours if already_billed else None,
|
|
"invoice" if billable else "internal", int((seconds / Decimal("60")).to_integral_value()),
|
|
int((seconds / Decimal("60")).to_integral_value()) if billable else 0,
|
|
start, "godkendt" if already_billed else "afventer",
|
|
),
|
|
)
|
|
if cursor.fetchone():
|
|
imported_time_entries += 1
|
|
|
|
cursor.execute(
|
|
"""INSERT INTO vtiger_case_hub_imports
|
|
(vtiger_case_id,ticket_number,hub_sag_id,hub_customer_id,imported_by_user_id,source_snapshot)
|
|
VALUES (%s,%s,%s,%s,%s,%s)""",
|
|
(vtiger_case_id, bundle["ticket_number"], sag_id, customer["id"], current_user_id,
|
|
Json({"case": case, "comments_not_imported": len(bundle.get("comments", []))})),
|
|
)
|
|
imported_now = True
|
|
conn.commit()
|
|
return {"hub_sag_id": sag_id, "imported": imported_now,
|
|
"time_entries": imported_time_entries,
|
|
"comments_available_not_imported": len(bundle.get("comments", [])) if imported_now else 0}
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
release_db_connection(conn)
|
|
|
|
|
|
def _safe_date(value: Any) -> Optional[date]:
|
|
if not value:
|
|
return None
|
|
if isinstance(value, date):
|
|
return value
|
|
try:
|
|
return date.fromisoformat(str(value)[:10])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _amount(value: Any) -> Decimal:
|
|
try:
|
|
return Decimal(str(value or 0).replace(",", "."))
|
|
except Exception:
|
|
return Decimal("0")
|
|
|
|
|
|
def _hub_interval(value: Any) -> str:
|
|
normalized = str(value or "").strip().lower()
|
|
if normalized in {"daily", "biweekly", "monthly", "quarterly", "yearly"}:
|
|
return normalized
|
|
if "quarter" in normalized or "kvart" in normalized or normalized.startswith("3_month"):
|
|
return "quarterly"
|
|
if "year" in normalized or "annual" in normalized or "årlig" in normalized:
|
|
return "yearly"
|
|
if "week" in normalized or "uge" in normalized:
|
|
return "biweekly"
|
|
return "monthly"
|
|
|
|
|
|
def _normalized_crm_record(source: str, raw: Dict[str, Any], service=None) -> Dict[str, Any]:
|
|
if source == "simply" and service:
|
|
data = service.extract_subscription_data(raw)
|
|
record_id = str(data.get("simplycrm_id") or raw.get("id") or "")
|
|
customer_id = str(data.get("account_id") or "")
|
|
product_name = data.get("name") or raw.get("subject") or "Simply abonnement"
|
|
amount = data.get("total_amount") or data.get("subtotal") or 0
|
|
frequency = data.get("billing_frequency")
|
|
start = _safe_date(data.get("start_date"))
|
|
end = _safe_date(data.get("end_date"))
|
|
active = data.get("status") != "cancelled"
|
|
else:
|
|
record_id = str(raw.get("id") or raw.get("subscriptionid") or "")
|
|
customer_id = str(raw.get("account_id") or raw.get("accountid") or "")
|
|
product_name = raw.get("subject") or raw.get("subscriptionname") or "Vtiger abonnement"
|
|
amount = raw.get("total") or raw.get("hdnGrandTotal") or raw.get("amount") or 0
|
|
frequency = str(raw.get("generateinvoiceevery") or raw.get("frequency") or "monthly").lower()
|
|
start = _safe_date(raw.get("startdate") or raw.get("start_period"))
|
|
end = _safe_date(raw.get("enddate") or raw.get("end_period"))
|
|
active = str(raw.get("subscriptionstatus") or "active").lower() not in {"cancelled", "inactive", "expired"}
|
|
normalized = {
|
|
"entity_type": "subscription", "source_system": source, "source_record_id": record_id,
|
|
"source_customer_id": customer_id, "customer_no": raw.get("customer_no") or raw.get("account_no"),
|
|
"customer_name": raw.get("accountname") or raw.get("customer_name") or customer_id,
|
|
"product_code": raw.get("product_code") or raw.get("productid"),
|
|
"product_name": product_name, "amount": _amount(amount), "quantity": _amount(raw.get("quantity") or 1),
|
|
"billing_frequency": frequency, "period_from": start, "period_to": end,
|
|
"invoice_no": None, "invoice_date": None, "source_payload": raw, "active": active,
|
|
}
|
|
normalized["source_hash"] = snapshot_hash(normalized)
|
|
return normalized
|
|
|
|
|
|
def _store_crm_snapshot(session_id: int, item: Dict[str, Any]) -> str:
|
|
if not item["source_record_id"]:
|
|
return "skipped"
|
|
if item.get("source_customer_id"):
|
|
customer_payload = {
|
|
"source_customer_id": item["source_customer_id"],
|
|
"customer_no": item.get("customer_no"),
|
|
"customer_name": item.get("customer_name") or item["source_customer_id"],
|
|
}
|
|
execute_query(
|
|
"""
|
|
INSERT INTO migration_center_source_customers
|
|
(source_system, source_customer_id, customer_no, customer_name, raw_payload, snapshot_hash)
|
|
VALUES (%s,%s,%s,%s,%s,%s)
|
|
ON CONFLICT (source_system, source_customer_id) DO UPDATE SET
|
|
customer_no=EXCLUDED.customer_no, customer_name=EXCLUDED.customer_name,
|
|
raw_payload=EXCLUDED.raw_payload, snapshot_hash=EXCLUDED.snapshot_hash,
|
|
updated_at=CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
item["source_system"], item["source_customer_id"], item.get("customer_no"),
|
|
customer_payload["customer_name"], Json(customer_payload), snapshot_hash(customer_payload),
|
|
), fetch=False,
|
|
)
|
|
existing = execute_query_single(
|
|
"""
|
|
SELECT id, source_hash, source_payload, lock_status
|
|
FROM migration_center_session_items
|
|
WHERE session_id=%s AND entity_type='subscription' AND source_system=%s AND source_record_id=%s
|
|
""",
|
|
(session_id, item["source_system"], item["source_record_id"]),
|
|
)
|
|
execute_query(
|
|
"""
|
|
INSERT INTO migration_center_source_subscriptions
|
|
(source_system, source_record_id, source_customer_id, customer_no, customer_name,
|
|
product_code, product_name, amount, quantity, billing_frequency, start_date, end_date,
|
|
active, raw_payload, snapshot_hash)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
ON CONFLICT (source_system, source_record_id) DO UPDATE SET
|
|
source_customer_id=EXCLUDED.source_customer_id, customer_no=EXCLUDED.customer_no,
|
|
customer_name=EXCLUDED.customer_name, product_code=EXCLUDED.product_code,
|
|
product_name=EXCLUDED.product_name, amount=EXCLUDED.amount, quantity=EXCLUDED.quantity,
|
|
billing_frequency=EXCLUDED.billing_frequency, start_date=EXCLUDED.start_date,
|
|
end_date=EXCLUDED.end_date, active=EXCLUDED.active, raw_payload=EXCLUDED.raw_payload,
|
|
snapshot_hash=EXCLUDED.snapshot_hash, updated_at=CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
item["source_system"], item["source_record_id"], item["source_customer_id"], item["customer_no"],
|
|
item["customer_name"], item["product_code"], item["product_name"], item["amount"],
|
|
item["quantity"], item["billing_frequency"], item["period_from"], item["period_to"],
|
|
item["active"], Json(json_value(item["source_payload"])), item["source_hash"],
|
|
), fetch=False,
|
|
)
|
|
if not existing:
|
|
execute_query(
|
|
"""
|
|
INSERT INTO migration_center_session_items
|
|
(session_id, entity_type, source_system, source_record_id, source_customer_id,
|
|
customer_no, customer_name, product_code, product_name, amount, quantity,
|
|
billing_frequency, period_from, period_to, source_payload, source_hash)
|
|
VALUES (%s,'subscription',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
""",
|
|
(
|
|
session_id, item["source_system"], item["source_record_id"], item["source_customer_id"],
|
|
item["customer_no"], item["customer_name"], item["product_code"], item["product_name"],
|
|
item["amount"], item["quantity"], item["billing_frequency"], item["period_from"],
|
|
item["period_to"], Json(json_value(item["source_payload"])), item["source_hash"],
|
|
), fetch=False,
|
|
)
|
|
return "created"
|
|
if existing["source_hash"] != item["source_hash"] and existing["lock_status"] != "locked":
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items SET
|
|
previous_source_payload=source_payload, source_payload=%s, source_hash=%s,
|
|
customer_no=%s, customer_name=%s, product_code=%s, product_name=%s, amount=%s,
|
|
quantity=%s, billing_frequency=%s, period_from=%s, period_to=%s,
|
|
match_status='source_changed', approval_status='pending',
|
|
verified_at=NULL, verified_by_user_id=NULL, updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=%s
|
|
""",
|
|
(
|
|
Json(json_value(item["source_payload"])), item["source_hash"], item["customer_no"],
|
|
item["customer_name"], item["product_code"], item["product_name"], item["amount"],
|
|
item["quantity"], item["billing_frequency"], item["period_from"], item["period_to"],
|
|
existing["id"],
|
|
), fetch=False,
|
|
)
|
|
return "changed"
|
|
return "unchanged"
|
|
|
|
|
|
def _load_item(item_id: int, *, for_update: bool = False, cursor=None) -> Dict[str, Any]:
|
|
suffix = " FOR UPDATE" if for_update else ""
|
|
query = f"SELECT * FROM migration_center_session_items WHERE id = %s{suffix}"
|
|
if cursor:
|
|
cursor.execute(query, (item_id,))
|
|
row = cursor.fetchone()
|
|
else:
|
|
row = execute_query_single(query, (item_id,))
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Post blev ikke fundet")
|
|
return dict(row)
|
|
|
|
|
|
def _ensure_item_mutable(item: Dict[str, Any]) -> None:
|
|
ensure_writable(int(item["session_id"]))
|
|
if item["lock_status"] not in MUTABLE_LOCK_STATES:
|
|
raise HTTPException(status_code=423, detail="Posten er låst eller ved at blive låst")
|
|
|
|
|
|
@router.get("/sessions")
|
|
async def list_sessions(current_user: dict = Depends(require_permission("migration_center.view"))):
|
|
return execute_query(
|
|
"""
|
|
SELECT s.*,
|
|
COUNT(i.id) AS item_count,
|
|
COUNT(i.id) FILTER (WHERE i.approval_status IN ('verified','ignored')) AS treated_count,
|
|
COUNT(i.id) FILTER (WHERE i.lock_status = 'locked') AS locked_count,
|
|
COUNT(i.id) FILTER (WHERE i.match_status IN ('conflict','source_changed')) AS conflict_count
|
|
FROM migration_center_sessions s
|
|
LEFT JOIN migration_center_session_items i ON i.session_id = s.id
|
|
GROUP BY s.id ORDER BY s.created_at DESC
|
|
"""
|
|
) or []
|
|
|
|
|
|
@router.post("/sessions")
|
|
async def add_session(
|
|
payload: SessionCreate,
|
|
request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.sessions")),
|
|
):
|
|
return create_session(payload.name, current_user, request)
|
|
|
|
|
|
@router.patch("/sessions/{session_id}")
|
|
async def update_session(
|
|
session_id: int,
|
|
payload: SessionUpdate,
|
|
request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.sessions")),
|
|
):
|
|
ensure_writable()
|
|
if payload.status and payload.status not in {"draft", "active", "completed", "archived"}:
|
|
raise HTTPException(status_code=400, detail="Ugyldig sessionsstatus")
|
|
existing = execute_query_single("SELECT * FROM migration_center_sessions WHERE id=%s", (session_id,))
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
|
|
status = payload.status or existing["status"]
|
|
read_only = payload.read_only if payload.read_only is not None else existing["read_only"]
|
|
if status in {"completed", "archived"}:
|
|
read_only = True
|
|
result = execute_query_single(
|
|
"""
|
|
UPDATE migration_center_sessions SET status=%s, read_only=%s, updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=%s RETURNING *
|
|
""",
|
|
(status, read_only, session_id),
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="session_updated", entity_type="session",
|
|
entity_id=session_id, session_id=session_id, old_value=existing, new_value=result,
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/sessions/{session_id}/crm-import")
|
|
async def import_crm_snapshots(
|
|
session_id: int,
|
|
request: Request,
|
|
source: str = Query(..., pattern="^(vtiger|simply)$"),
|
|
mock: bool = False,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
ensure_writable(session_id)
|
|
if mock:
|
|
records = [{
|
|
"id": f"mock-{source}-1", "account_id": "mock-customer-1",
|
|
"accountname": "Eksempel Kunde A/S", "subject": "Microsoft 365 Business Premium",
|
|
"hdnGrandTotal": "1295.00", "quantity": 5, "recurring_frequency": "Monthly",
|
|
"start_period": date.today().replace(day=1).isoformat(), "enable_recurring": "1",
|
|
}]
|
|
service = SimplyCRMService() if source == "simply" else None
|
|
elif source == "simply":
|
|
async with SimplyCRMService() as service:
|
|
records = await service.fetch_active_subscriptions()
|
|
else:
|
|
service = None
|
|
records = await get_vtiger_service().query("SELECT * FROM Subscription;")
|
|
counts = {"created": 0, "changed": 0, "unchanged": 0, "skipped": 0}
|
|
item_ids = []
|
|
for raw in records:
|
|
item = _normalized_crm_record(source, dict(raw), service)
|
|
outcome = _store_crm_snapshot(session_id, item)
|
|
counts[outcome] += 1
|
|
row = execute_query_single(
|
|
"""
|
|
SELECT id FROM migration_center_session_items
|
|
WHERE session_id=%s AND entity_type='subscription' AND source_system=%s AND source_record_id=%s
|
|
""",
|
|
(session_id, source, item["source_record_id"]),
|
|
)
|
|
if row:
|
|
item_ids.append(row["id"])
|
|
for item_id in item_ids:
|
|
row = _load_item(item_id)
|
|
if row["match_status"] != "source_changed":
|
|
match_item(item_id)
|
|
relevance = refresh_subscription_relevance(session_id)
|
|
audit(
|
|
request=request, current_user=current_user, action="crm_imported", entity_type="session",
|
|
entity_id=session_id, session_id=session_id, new_value={"source": source, **counts},
|
|
)
|
|
return {"source": source, "records": len(records), **counts, **relevance}
|
|
|
|
|
|
@router.post("/sessions/{session_id}/economic-snapshot")
|
|
async def load_economic_snapshot(
|
|
session_id: int,
|
|
request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
ensure_writable(session_id)
|
|
run = EconomicSnapshotRepository.latest_run()
|
|
if not run:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Faktura-fejl-finder har ingen anvendelig import med fakturaer",
|
|
)
|
|
counts = attach_economic_snapshot(session_id, run)
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items i
|
|
SET hub_customer_id=c.id,
|
|
customer_name=COALESCE(NULLIF(i.customer_name,''),c.name),
|
|
hub_status=CASE WHEN i.hub_status='not_created' THEN 'ready_for_creation' ELSE i.hub_status END,
|
|
match_explanation=CASE
|
|
WHEN NOT (i.match_explanation ? 'e-conomic-kundenummer stemmer')
|
|
THEN i.match_explanation || '["e-conomic-kundenummer stemmer"]'::jsonb
|
|
ELSE i.match_explanation
|
|
END,
|
|
updated_at=CURRENT_TIMESTAMP
|
|
FROM customers c
|
|
WHERE i.session_id=%s AND i.source_system='economic'
|
|
AND i.lock_status IN ('unlocked','lock_failed')
|
|
AND NULLIF(i.customer_no,'') IS NOT NULL
|
|
AND c.economic_customer_number::text=i.customer_no
|
|
AND c.deleted_at IS NULL
|
|
""",
|
|
(session_id,),
|
|
fetch=False,
|
|
)
|
|
mapped_row = execute_query_single(
|
|
"""
|
|
SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE hub_customer_id IS NOT NULL) AS mapped
|
|
FROM migration_center_session_items WHERE session_id=%s AND source_system='economic'
|
|
""",
|
|
(session_id,),
|
|
)
|
|
relevance = refresh_subscription_relevance(session_id)
|
|
result = {
|
|
**counts, "processed": int(mapped_row["total"]), "customers_mapped": int(mapped_row["mapped"]),
|
|
"import_run_id": run["id"], "snapshot_at": run["completed_at"], **relevance,
|
|
}
|
|
audit(
|
|
request=request, current_user=current_user, action="economic_snapshot_attached",
|
|
entity_type="session", entity_id=session_id, session_id=session_id, new_value=result,
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/sessions/{session_id}/dashboard")
|
|
async def dashboard(
|
|
session_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
session = execute_query_single(
|
|
"""
|
|
SELECT s.*, r.status AS economic_import_status, r.records_imported, r.records_failed
|
|
FROM migration_center_sessions s
|
|
LEFT JOIN invoice_error_finder_import_runs r ON r.id = s.economic_import_run_id
|
|
WHERE s.id = %s
|
|
""",
|
|
(session_id,),
|
|
)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
|
|
counts = execute_query_single(
|
|
f"""
|
|
SELECT COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE entity_type='subscription') AS subscriptions,
|
|
COUNT(*) FILTER (WHERE entity_type='invoice_line') AS invoice_lines,
|
|
COUNT(*) FILTER (WHERE hub_status='ready_for_creation') AS ready,
|
|
COUNT(*) FILTER (WHERE hub_status='created_in_hub') AS created,
|
|
COUNT(*) FILTER (WHERE approval_status='ignored') AS ignored,
|
|
COUNT(*) FILTER (WHERE match_status IN ('conflict','source_changed')) AS conflicts,
|
|
COUNT(*) FILTER (WHERE match_status='source_changed') AS source_changed,
|
|
COUNT(*) FILTER (WHERE lock_status='locked') AS locked
|
|
FROM migration_center_session_items
|
|
WHERE session_id=%s
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql()}
|
|
""",
|
|
(session_id,),
|
|
)
|
|
return {
|
|
"session": session,
|
|
"counts": counts or {},
|
|
"read_only": bool(getattr(settings, "MIGRATION_CENTER_READ_ONLY", False) or session["read_only"]),
|
|
"stale_after_days": int(getattr(settings, "MIGRATION_CENTER_STALE_AFTER_DAYS", 7)),
|
|
}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/company-options")
|
|
async def company_options(
|
|
session_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
return execute_query(
|
|
f"""
|
|
SELECT customer_name, MAX(hub_customer_id) AS hub_customer_id, COUNT(*) AS item_count
|
|
FROM migration_center_session_items
|
|
WHERE session_id=%s AND NULLIF(TRIM(customer_name),'') IS NOT NULL
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql()}
|
|
GROUP BY customer_name
|
|
ORDER BY LOWER(customer_name)
|
|
""",
|
|
(session_id,),
|
|
) or []
|
|
|
|
|
|
@router.get("/sessions/{session_id}/invoice-history")
|
|
async def invoice_history(
|
|
session_id: int,
|
|
only_subscription_candidates: bool = False,
|
|
company_name: Optional[str] = None,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
candidate_filter = """
|
|
WHERE NOT has_hub_subscription AND invoice_count >= 1 AND invoiced_months >= 1
|
|
AND max_amount > 0 AND LOWER(product_name) NOT LIKE '%%gebyr%%'
|
|
""" if only_subscription_candidates else ""
|
|
rows = execute_query(
|
|
f"""
|
|
WITH grouped AS (
|
|
SELECT
|
|
COALESCE(NULLIF(customer_no,''), source_customer_id, customer_name) AS customer_key,
|
|
MAX(customer_name) AS customer_name,
|
|
MAX(customer_no) AS customer_no,
|
|
MAX(hub_customer_id) AS hub_customer_id,
|
|
COALESCE(NULLIF(product_code,''), LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g'))) AS product_key,
|
|
MAX(product_code) AS product_code,
|
|
MAX(product_name) AS product_name,
|
|
COUNT(DISTINCT invoice_no) AS invoice_count,
|
|
COUNT(DISTINCT DATE_TRUNC('month',invoice_date)) AS invoiced_months,
|
|
MIN(invoice_date) AS first_invoice_date,
|
|
MAX(invoice_date) AS last_invoice_date,
|
|
MIN(amount) AS min_amount,
|
|
MAX(amount) AS max_amount,
|
|
SUM(amount) AS total_amount,
|
|
BOOL_OR(hub_record_id IS NOT NULL OR suggested_hub_record_id IS NOT NULL) AS explicitly_linked,
|
|
MIN(id) AS representative_item_id
|
|
FROM migration_center_session_items mci
|
|
WHERE session_id=%s AND source_system='economic' AND entity_type='invoice_line'
|
|
AND invoice_date >= (DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '12 months')::date
|
|
AND invoice_date <= CURRENT_DATE
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql('mci')}
|
|
AND (%s IS NULL OR LOWER(customer_name)=LOWER(%s))
|
|
GROUP BY
|
|
COALESCE(NULLIF(customer_no,''), source_customer_id, customer_name),
|
|
COALESCE(NULLIF(product_code,''), LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g')))
|
|
), matched AS (
|
|
SELECT grouped.*,
|
|
(
|
|
explicitly_linked OR EXISTS (
|
|
SELECT 1 FROM sag_subscriptions s
|
|
WHERE s.customer_id=grouped.hub_customer_id
|
|
AND s.status <> 'cancelled'
|
|
AND (
|
|
LOWER(TRIM(COALESCE(s.product_name,'')))=LOWER(TRIM(grouped.product_name))
|
|
OR ABS(COALESCE(s.price,0)-COALESCE(grouped.max_amount,0)) <= 0.01
|
|
)
|
|
)
|
|
) AS has_hub_subscription
|
|
FROM grouped
|
|
)
|
|
SELECT *,
|
|
CASE
|
|
WHEN invoiced_months >= 10 THEN 'monthly'
|
|
WHEN invoiced_months >= 4 AND
|
|
(last_invoice_date-first_invoice_date)/GREATEST(invoiced_months-1,1) BETWEEN 60 AND 120
|
|
THEN 'quarterly'
|
|
WHEN invoice_count >= 2 AND last_invoice_date-first_invoice_date >= 300 THEN 'yearly'
|
|
ELSE 'irregular'
|
|
END AS suggested_frequency,
|
|
(
|
|
NOT has_hub_subscription AND invoice_count >= 1 AND invoiced_months >= 1
|
|
AND max_amount > 0 AND LOWER(product_name) NOT LIKE '%%gebyr%%'
|
|
) AS subscription_candidate
|
|
FROM matched
|
|
{candidate_filter}
|
|
ORDER BY subscription_candidate DESC, invoiced_months DESC, customer_name, product_name
|
|
""",
|
|
(session_id, company_name, company_name),
|
|
) or []
|
|
return {"months": 13, "groups": rows, "count": len(rows)}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/items")
|
|
async def list_items(
|
|
session_id: int,
|
|
q: Optional[str] = None,
|
|
company_name: Optional[str] = None,
|
|
entity_type: Optional[str] = None,
|
|
source_system: Optional[str] = None,
|
|
match_status: Optional[str] = None,
|
|
approval_status: Optional[str] = None,
|
|
hub_status: Optional[str] = None,
|
|
lock_status: Optional[str] = None,
|
|
only_deviations: bool = False,
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
sort: str = "invoice_date",
|
|
direction: str = "desc",
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
allowed_sort = {
|
|
"invoice_date": "invoice_date", "customer": "customer_name", "product": "product_name",
|
|
"amount": "amount", "status": "match_status", "created": "created_at",
|
|
}
|
|
clauses = ["session_id = %s"]
|
|
params: list[Any] = [session_id]
|
|
filters = {
|
|
"entity_type": entity_type, "source_system": source_system, "match_status": match_status,
|
|
"approval_status": approval_status, "hub_status": hub_status, "lock_status": lock_status,
|
|
}
|
|
for column, value in filters.items():
|
|
if value:
|
|
clauses.append(f"{column} = %s")
|
|
params.append(value)
|
|
if only_deviations:
|
|
clauses.append("(match_status IN ('no_match','conflict','manual_review','source_changed') OR hub_status='not_created')")
|
|
if q:
|
|
clauses.append(
|
|
"(customer_name ILIKE %s OR customer_no ILIKE %s OR product_name ILIKE %s "
|
|
"OR product_code ILIKE %s OR invoice_no ILIKE %s OR source_record_id ILIKE %s)"
|
|
)
|
|
term = f"%{q.strip()}%"
|
|
params.extend([term] * 6)
|
|
if company_name:
|
|
clauses.append("LOWER(customer_name) = LOWER(%s)")
|
|
params.append(company_name.strip())
|
|
clauses.append(EXCLUDED_INVOICE_LINE_SQL)
|
|
clauses.append(subscription_like_item_sql())
|
|
where = " AND ".join(clauses)
|
|
total = execute_query_single(f"SELECT COUNT(*) AS count FROM migration_center_session_items WHERE {where}", tuple(params))
|
|
order = allowed_sort.get(sort, "invoice_date")
|
|
direction_sql = "ASC" if direction.lower() == "asc" else "DESC"
|
|
rows = execute_query(
|
|
f"""
|
|
SELECT * FROM migration_center_session_items
|
|
WHERE {where}
|
|
ORDER BY {order} {direction_sql} NULLS LAST, id DESC
|
|
LIMIT %s OFFSET %s
|
|
""",
|
|
tuple(params + [page_size, (page - 1) * page_size]),
|
|
) or []
|
|
return {"items": rows, "total": int((total or {}).get("count", 0)), "page": page, "page_size": page_size}
|
|
|
|
|
|
@router.get("/items/{item_id}")
|
|
async def get_item(
|
|
item_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
return _load_item(item_id)
|
|
|
|
|
|
@router.get("/items/{item_id}/context")
|
|
async def get_item_context(
|
|
item_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
item = _load_item(item_id)
|
|
params: list[Any] = [item["session_id"]]
|
|
relations = []
|
|
if item.get("hub_customer_id"):
|
|
relations.append("related.hub_customer_id=%s")
|
|
params.append(item["hub_customer_id"])
|
|
if item.get("customer_no"):
|
|
relations.append("(NULLIF(related.customer_no,'') IS NOT NULL AND related.customer_no=%s)")
|
|
params.append(item["customer_no"])
|
|
if item.get("source_customer_id"):
|
|
relations.append(
|
|
"(related.source_system=%s AND related.source_customer_id=%s)"
|
|
)
|
|
params.extend([item["source_system"], item["source_customer_id"]])
|
|
relation_sql = " OR ".join(relations) or "related.id=%s"
|
|
if not relations:
|
|
params.append(item_id)
|
|
related = execute_query(
|
|
f"""
|
|
SELECT related.*
|
|
FROM migration_center_session_items related
|
|
WHERE related.session_id=%s AND ({relation_sql})
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql('related')}
|
|
ORDER BY
|
|
CASE related.source_system
|
|
WHEN 'economic' THEN 1 WHEN 'vtiger' THEN 2 WHEN 'simply' THEN 3 ELSE 4
|
|
END,
|
|
related.invoice_date DESC NULLS LAST, related.period_from DESC NULLS LAST, related.id DESC
|
|
LIMIT 500
|
|
""",
|
|
tuple(params),
|
|
) or []
|
|
by_source = {"economic": [], "vtiger": [], "simply": []}
|
|
for row in related:
|
|
source = row.get("source_system")
|
|
if source in by_source:
|
|
by_source[source].append(row)
|
|
|
|
hub_customer = None
|
|
hub_cases = []
|
|
hub_subscriptions = []
|
|
if item.get("hub_customer_id"):
|
|
hub_customer = execute_query_single(
|
|
"""
|
|
SELECT id, name, cvr_number, email, phone, economic_customer_number, vtiger_id
|
|
FROM customers WHERE id=%s
|
|
""",
|
|
(item["hub_customer_id"],),
|
|
)
|
|
hub_cases = execute_query(
|
|
"""
|
|
SELECT id, titel, status, template_key, created_at
|
|
FROM sag_sager WHERE customer_id=%s
|
|
ORDER BY created_at DESC LIMIT 100
|
|
""",
|
|
(item["hub_customer_id"],),
|
|
) or []
|
|
hub_subscriptions = execute_query(
|
|
"""
|
|
SELECT s.id, s.subscription_number, s.sag_id, s.product_name, s.price,
|
|
s.billing_interval, s.start_date, s.end_date, s.status,
|
|
COALESCE(
|
|
JSONB_AGG(
|
|
JSONB_BUILD_OBJECT(
|
|
'id', line.id, 'description', line.description, 'quantity', line.quantity,
|
|
'unit_price', line.unit_price, 'line_total', line.line_total
|
|
) ORDER BY line.line_no
|
|
) FILTER (WHERE line.id IS NOT NULL),
|
|
'[]'::jsonb
|
|
) AS lines
|
|
FROM sag_subscriptions s
|
|
LEFT JOIN sag_subscription_items line ON line.subscription_id=s.id
|
|
WHERE s.customer_id=%s
|
|
GROUP BY s.id
|
|
ORDER BY s.updated_at DESC LIMIT 100
|
|
""",
|
|
(item["hub_customer_id"],),
|
|
) or []
|
|
return {
|
|
"item": item,
|
|
"sources": by_source,
|
|
"hub": {
|
|
"customer": hub_customer,
|
|
"cases": hub_cases,
|
|
"subscriptions": hub_subscriptions,
|
|
},
|
|
"counts": {
|
|
"economic": len(by_source["economic"]),
|
|
"vtiger": len(by_source["vtiger"]),
|
|
"simply": len(by_source["simply"]),
|
|
"hub_subscriptions": len(hub_subscriptions),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/queue/next")
|
|
async def next_queue_item(
|
|
session_id: int,
|
|
after_id: Optional[int] = None,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
params: list[Any] = [session_id]
|
|
after = ""
|
|
if after_id:
|
|
after = " AND id > %s"
|
|
params.append(after_id)
|
|
row = execute_query_single(
|
|
f"""
|
|
SELECT * FROM migration_center_session_items
|
|
WHERE session_id=%s AND approval_status='pending'
|
|
AND lock_status IN ('unlocked','lock_failed')
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql()} {after}
|
|
ORDER BY CASE match_status WHEN 'conflict' THEN 0 WHEN 'source_changed' THEN 1
|
|
WHEN 'manual_review' THEN 2 ELSE 3 END, id
|
|
LIMIT 1
|
|
""",
|
|
tuple(params),
|
|
)
|
|
return {"item": row}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/customers/{customer_id}")
|
|
async def customer_overview(
|
|
session_id: int,
|
|
customer_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
customer = execute_query_single(
|
|
"""
|
|
SELECT id, name, cvr_number, email, economic_customer_number
|
|
FROM customers WHERE id=%s AND deleted_at IS NULL
|
|
""",
|
|
(customer_id,),
|
|
)
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
|
|
items = execute_query(
|
|
f"""
|
|
SELECT * FROM migration_center_session_items
|
|
WHERE session_id=%s AND hub_customer_id=%s
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql()}
|
|
ORDER BY entity_type, invoice_date DESC NULLS LAST, id
|
|
""",
|
|
(session_id, customer_id),
|
|
) or []
|
|
subscriptions = execute_query(
|
|
"""
|
|
SELECT id, subscription_number, product_name, price, billing_interval, status, start_date, end_date
|
|
FROM sag_subscriptions WHERE customer_id=%s ORDER BY updated_at DESC
|
|
""",
|
|
(customer_id,),
|
|
) or []
|
|
open_conflicts = [
|
|
row["id"] for row in items if row["match_status"] in {"conflict", "source_changed"}
|
|
]
|
|
untreated = [
|
|
row["id"] for row in items if row["approval_status"] not in {"verified", "ignored"}
|
|
]
|
|
return {
|
|
"customer": customer, "items": items, "hub_subscriptions": subscriptions,
|
|
"open_conflicts": open_conflicts, "untreated": untreated,
|
|
"can_lock_all": bool(items) and not open_conflicts and not untreated,
|
|
}
|
|
|
|
|
|
@router.post("/vtiger-cases/preview")
|
|
async def preview_vtiger_case_import(
|
|
payload: VtigerCaseImportRequest,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
bundle = await _load_vtiger_case_bundle(payload.ticket_number)
|
|
raw = bundle["case"]
|
|
already_imported = bundle.get("existing_import")
|
|
eligible_timelogs = [row for row in bundle.get("timelogs", []) if _amount(row.get("duration")) > 0]
|
|
return {
|
|
"ticket_number": bundle["ticket_number"],
|
|
"vtiger_case_id": bundle["vtiger_case_id"],
|
|
"title": _vtiger_text(raw.get("ticket_title") or raw.get("subject")) or bundle["ticket_number"],
|
|
"description": _vtiger_plain_text(raw.get("description")),
|
|
"status": _case_status_from_vtiger(raw.get("status")),
|
|
"priority": _case_priority_from_vtiger(raw.get("priority")),
|
|
"customer": bundle["customer"],
|
|
"contact": bundle.get("contact"),
|
|
"time_entries": len(eligible_timelogs),
|
|
"time_hours": str(sum((_amount(row.get("duration")) for row in eligible_timelogs), Decimal("0")) / Decimal("3600")),
|
|
"comments_available_not_imported": len(bundle.get("comments", [])),
|
|
"already_imported": bool(already_imported),
|
|
"hub_sag_id": already_imported.get("hub_sag_id") if already_imported else None,
|
|
"vtiger_marked": bool(already_imported and already_imported.get("vtiger_marked_at")),
|
|
}
|
|
|
|
|
|
@router.post("/vtiger-cases/import")
|
|
async def import_vtiger_case(
|
|
payload: VtigerCaseImportRequest,
|
|
request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
bundle = await _load_vtiger_case_bundle(payload.ticket_number)
|
|
result = _import_vtiger_case_bundle(bundle, current_user)
|
|
marked = bool(bundle.get("existing_import") and bundle["existing_import"].get("vtiger_marked_at"))
|
|
mark_error = None
|
|
if not marked:
|
|
configured_field = getattr(settings, "VTIGER_CASE_HUBBED_FIELD", "cf_cases_hubbet") or "cf_cases_hubbet"
|
|
if not re.fullmatch(r"cf_[A-Za-z0-9_]+", configured_field):
|
|
mark_error = "VTIGER_CASE_HUBBED_FIELD er ikke konfigureret med et gyldigt VTiger-feltnavn"
|
|
else:
|
|
try:
|
|
await get_vtiger_service().update_record_fields("Cases", bundle["vtiger_case_id"], {configured_field: "1"})
|
|
marked = True
|
|
execute_query(
|
|
"UPDATE vtiger_case_hub_imports SET vtiger_marked_at=CURRENT_TIMESTAMP WHERE vtiger_case_id=%s",
|
|
(bundle["vtiger_case_id"],), fetch=False,
|
|
)
|
|
except Exception as exc:
|
|
mark_error = f"Sagen blev overført, men vTiger kunne ikke markeres Hubbet: {str(exc)[:300]}"
|
|
audit(
|
|
request=request, current_user=current_user, action="vtiger_case_imported",
|
|
entity_type="case", entity_id=result["hub_sag_id"],
|
|
new_value={"vtiger_case_id": bundle["vtiger_case_id"], "ticket_number": bundle["ticket_number"],
|
|
"imported": result["imported"], "vtiger_marked": marked},
|
|
)
|
|
return {"success": True, **result, "vtiger_marked": marked, "warning": mark_error}
|
|
|
|
|
|
def _hub_status_to_vtiger(value: Any, allowed: set[str]) -> str:
|
|
key = _vtiger_text(value).strip().lower()
|
|
candidates = {
|
|
"ny": "New", "tildelt": "Assigned", "igangværende": "In Progress",
|
|
"afventer": "Wait for customer", "løst": "Resolved", "lukket": "Closed",
|
|
"kunde svaret": "Open", "åben": "Open",
|
|
}
|
|
candidate = candidates.get(key, "New")
|
|
return candidate if candidate in allowed else ("Open" if "Open" in allowed else next(iter(allowed), "New"))
|
|
|
|
|
|
def _hub_priority_to_vtiger(value: Any, allowed: set[str]) -> str:
|
|
key = _vtiger_text(value).strip().lower()
|
|
candidates = {"urgent": "Urgent", "high": "High", "normal": "Medium", "low": "Low"}
|
|
candidate = candidates.get(key, "Medium")
|
|
return candidate if candidate in allowed else ("Medium" if "Medium" in allowed else next(iter(allowed), candidate))
|
|
|
|
|
|
def _time_export_minutes(row: Dict[str, Any]) -> int:
|
|
actual = row.get("faktisk_tid_min")
|
|
if actual not in (None, "") and int(actual or 0) > 0:
|
|
return int(actual)
|
|
hours = row.get("original_hours") or row.get("approved_hours") or 0
|
|
return max(0, int((_amount(hours) * Decimal("60")).to_integral_value()))
|
|
|
|
|
|
def _time_export_billable(row: Dict[str, Any]) -> bool:
|
|
method = _vtiger_text(row.get("billing_method")).lower()
|
|
return bool(row.get("billable")) and method in {"", "invoice", "faktura"}
|
|
|
|
|
|
def _normalized_person(value: Any) -> str:
|
|
return " ".join(_vtiger_text(value).casefold().split())
|
|
|
|
|
|
async def _load_hub_case_export_bundle(sag_id: int) -> Dict[str, Any]:
|
|
case = execute_query_single(
|
|
"""SELECT s.id, s.titel, s.beskrivelse, s.status, s.priority::text AS priority,
|
|
s.customer_id, c.name AS customer_name, c.vtiger_id AS account_vtiger_id,
|
|
s.ansvarlig_bruger_id, owner.full_name AS owner_name, owner.email AS owner_email
|
|
FROM sag_sager s
|
|
JOIN customers c ON c.id=s.customer_id
|
|
LEFT JOIN users owner ON owner.user_id=s.ansvarlig_bruger_id
|
|
WHERE s.id=%s AND s.deleted_at IS NULL""",
|
|
(sag_id,),
|
|
)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail=f"Hub-sag #{sag_id} blev ikke fundet")
|
|
contact = execute_query_single(
|
|
"""SELECT c.id, c.first_name, c.last_name, c.email, c.vtiger_id
|
|
FROM sag_kontakter sc JOIN contacts c ON c.id=sc.contact_id
|
|
WHERE sc.sag_id=%s AND sc.deleted_at IS NULL AND c.vtiger_id IS NOT NULL
|
|
ORDER BY sc.is_primary DESC, sc.id LIMIT 1""",
|
|
(sag_id,),
|
|
)
|
|
if not contact:
|
|
contact = execute_query_single(
|
|
"""SELECT c.id, c.first_name, c.last_name, c.email, c.vtiger_id
|
|
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id
|
|
WHERE cc.customer_id=%s AND cc.is_primary IS TRUE AND c.vtiger_id IS NOT NULL
|
|
ORDER BY cc.id LIMIT 1""",
|
|
(case["customer_id"],),
|
|
)
|
|
times = execute_query(
|
|
"""SELECT t.id, t.description, t.worked_date, t.start_tid, t.slut_tid,
|
|
t.original_hours, t.approved_hours, t.faktisk_tid_min, t.fakturerbar_tid_min,
|
|
t.billable, t.billing_method, t.status, t.entry_status, t.vtiger_id,
|
|
t.user_name, t.medarbejder_id, u.full_name AS technician_name,
|
|
u.email AS technician_email
|
|
FROM tmodule_times t
|
|
LEFT JOIN users u ON u.user_id=t.medarbejder_id
|
|
WHERE t.sag_id=%s AND t.vtiger_id IS NULL
|
|
AND COALESCE(t.entry_status, 'godkendt')='godkendt'
|
|
AND COALESCE(t.status, '') NOT IN ('billed', 'rejected')
|
|
AND t.billed_via_thehub_id IS NULL AND t.economy_settled_at IS NULL
|
|
AND t.economy_order_draft_id IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM hub_time_vtiger_exports x WHERE x.hub_time_id=t.id)
|
|
ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at), t.id""",
|
|
(sag_id,),
|
|
) or []
|
|
all_times = execute_query_single(
|
|
"""SELECT COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE COALESCE(entry_status, 'godkendt') <> 'godkendt') AS pending
|
|
FROM tmodule_times WHERE sag_id=%s""",
|
|
(sag_id,),
|
|
) or {"total": 0, "pending": 0}
|
|
export_mapping = execute_query_single(
|
|
"SELECT vtiger_case_id, ticket_number, status FROM hub_case_vtiger_exports WHERE hub_sag_id=%s",
|
|
(sag_id,),
|
|
)
|
|
imported_mapping = execute_query_single(
|
|
"SELECT vtiger_case_id, ticket_number FROM vtiger_case_hub_imports WHERE hub_sag_id=%s",
|
|
(sag_id,),
|
|
)
|
|
return {
|
|
"case": case, "contact": contact, "times": times,
|
|
"pending_time_count": int(all_times.get("pending") or 0),
|
|
"export_mapping": export_mapping, "imported_mapping": imported_mapping,
|
|
}
|
|
|
|
|
|
async def _vtiger_user_records(vtiger) -> list[Dict[str, Any]]:
|
|
records: list[Dict[str, Any]] = []
|
|
for offset in range(0, 500, 100):
|
|
batch = await vtiger.query(
|
|
"SELECT id, first_name, last_name, user_name, email1 FROM Users "
|
|
f"LIMIT {offset}, 100;"
|
|
)
|
|
records.extend(batch or [])
|
|
if len(batch or []) < 100:
|
|
break
|
|
return records
|
|
|
|
|
|
def _match_vtiger_user(row: Dict[str, Any], users: list[Dict[str, Any]]) -> Optional[str]:
|
|
email = _normalized_person(row.get("technician_email"))
|
|
name = _normalized_person(row.get("technician_name") or row.get("user_name"))
|
|
matches = []
|
|
for user in users:
|
|
user_id = _vtiger_reference_id(user.get("id"))
|
|
user_name = " ".join(
|
|
part for part in (_vtiger_text(user.get("first_name")), _vtiger_text(user.get("last_name"))) if part
|
|
).strip()
|
|
if not user_id:
|
|
continue
|
|
if email and email in {_normalized_person(user.get("email1")), _normalized_person(user.get("user_name"))}:
|
|
return user_id
|
|
if name and name in {_normalized_person(user_name), _normalized_person(user.get("user_name"))}:
|
|
matches.append(user_id)
|
|
return matches[0] if len(set(matches)) == 1 else None
|
|
|
|
|
|
def _export_time_payload(row: Dict[str, Any], sag_id: int, vtiger_case_id: str, vtiger_user_id: str) -> Dict[str, Any]:
|
|
minutes = _time_export_minutes(row)
|
|
start = row.get("start_tid")
|
|
if not isinstance(start, datetime):
|
|
work_date = row.get("worked_date")
|
|
if isinstance(work_date, datetime):
|
|
work_date = work_date.date()
|
|
if not isinstance(work_date, date):
|
|
raise ValueError(f"Tidsregistrering #{row['id']} mangler en gyldig dato")
|
|
start = datetime.combine(work_date, datetime_time(9, 0))
|
|
duration_seconds = minutes * 60
|
|
description = _vtiger_text(row.get("description"))
|
|
name = description[:250] or f"Hub sag #{sag_id} · {start:%Y-%m-%d}"
|
|
method = _vtiger_text(row.get("billing_method")).lower()
|
|
return {
|
|
"name": name,
|
|
"description": description,
|
|
"relatedto": vtiger_case_id,
|
|
"startedon": start.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"endedon": (start + timedelta(seconds=duration_seconds)).strftime("%Y-%m-%d %H:%M:%S"),
|
|
"duration": str(duration_seconds),
|
|
"assigned_user_id": vtiger_user_id,
|
|
"isbillable": "1" if _time_export_billable(row) else "0",
|
|
"timelog_status": "Completed",
|
|
"cf_timelog_invoiced": "0",
|
|
}
|
|
|
|
|
|
async def _prepare_hub_case_export(sag_id: int) -> Dict[str, Any]:
|
|
bundle = await _load_hub_case_export_bundle(sag_id)
|
|
case = bundle["case"]
|
|
blockers = []
|
|
if bundle.get("imported_mapping"):
|
|
blockers.append("Sagen stammer allerede fra vTiger og kan ikke oprettes dér igen.")
|
|
if not case.get("account_vtiger_id"):
|
|
blockers.append("Kunden mangler en VTiger-kontakt/linkning.")
|
|
if not bundle.get("contact") or not bundle["contact"].get("vtiger_id"):
|
|
blockers.append("Sagen skal have en primær kontakt, der er linket til VTiger.")
|
|
vtiger = get_vtiger_service()
|
|
case_meta = await vtiger.describe_module("Cases")
|
|
timelog_meta = await vtiger.describe_module("Timelog")
|
|
fields = {f.get("name"): f for f in case_meta.get("fields", [])}
|
|
time_fields = {f.get("name"): f for f in timelog_meta.get("fields", [])}
|
|
required_case = [name for name, field in fields.items() if field.get("mandatory") and field.get("editable")]
|
|
required_time = [name for name, field in time_fields.items() if field.get("mandatory") and field.get("editable")]
|
|
if set(required_case) - {"title", "description", "casestatus", "casepriority", "parent_id", "contact_id", "cf_cases_hubbet"}:
|
|
blockers.append("VTiger har nye obligatoriske sagsfelter, som endnu ikke er mappet.")
|
|
if set(required_time) - {"name", "description", "relatedto", "startedon", "endedon", "assigned_user_id", "duration", "isbillable", "timelog_status", "cf_timelog_invoiced"}:
|
|
blockers.append("VTiger har nye obligatoriske tidsfelter, som endnu ikke er mappet.")
|
|
if any(name not in fields for name in ("title", "description", "casestatus", "casepriority", "parent_id", "contact_id")):
|
|
blockers.append("VTigers sagsfelter svarer ikke til den understøttede overførsel.")
|
|
if any(name not in time_fields for name in ("name", "description", "relatedto", "startedon", "endedon", "assigned_user_id", "duration", "isbillable", "timelog_status")):
|
|
blockers.append("VTigers tidsregistreringsfelter svarer ikke til den understøttede overførsel.")
|
|
allowed_statuses = {str(p.get("value")) for p in fields.get("casestatus", {}).get("type", {}).get("picklistValues", []) if p.get("value")}
|
|
allowed_priorities = {str(p.get("value")) for p in fields.get("casepriority", {}).get("type", {}).get("picklistValues", []) if p.get("value")}
|
|
users = await _vtiger_user_records(vtiger) if bundle["times"] else []
|
|
enriched_times = []
|
|
for row in bundle["times"]:
|
|
technician_name = _vtiger_text(row.get("technician_name") or row.get("user_name"))
|
|
vtiger_user_id = _match_vtiger_user(row, users)
|
|
if not vtiger_user_id:
|
|
blockers.append(f"Teknikeren på tidsregistrering #{row['id']} matcher ikke en VTiger-bruger.")
|
|
enriched_times.append({
|
|
**row,
|
|
"technician_name": technician_name or "Ukendt tekniker",
|
|
"vtiger_user_id": vtiger_user_id,
|
|
"minutes": _time_export_minutes(row),
|
|
"export_billable": _time_export_billable(row),
|
|
})
|
|
case_hash_data = {
|
|
"case": {key: str(case.get(key) or "") for key in ("id", "titel", "beskrivelse", "status", "priority", "account_vtiger_id")},
|
|
"contact": {key: str(bundle["contact"].get(key) or "") for key in ("vtiger_id", "email")} if bundle.get("contact") else {},
|
|
"times": [
|
|
{key: str(row.get(key) or "") for key in ("id", "description", "worked_date", "start_tid", "original_hours", "faktisk_tid_min", "fakturerbar_tid_min", "billable", "billing_method", "technician_email", "technician_name")}
|
|
for row in enriched_times
|
|
],
|
|
}
|
|
source_hash = hashlib.sha256(json.dumps(case_hash_data, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
|
return {
|
|
**bundle,
|
|
"times": enriched_times,
|
|
"blockers": sorted(set(blockers)),
|
|
"source_hash": source_hash,
|
|
"case_meta": case_meta,
|
|
"allowed_statuses": allowed_statuses,
|
|
"allowed_priorities": allowed_priorities,
|
|
"required_case_fields": required_case,
|
|
"required_time_fields": required_time,
|
|
"status_value": _hub_status_to_vtiger(case.get("status"), allowed_statuses),
|
|
"priority_value": _hub_priority_to_vtiger(case.get("priority"), allowed_priorities),
|
|
}
|
|
|
|
|
|
@router.post("/hub-cases/preview")
|
|
async def preview_hub_case_export(
|
|
payload: HubCaseExportRequest,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
data = await _prepare_hub_case_export(payload.sag_id)
|
|
case = data["case"]
|
|
mapping = data.get("export_mapping")
|
|
return {
|
|
"sag_id": case["id"], "title": case["titel"],
|
|
"description": _vtiger_plain_text(case.get("beskrivelse")),
|
|
"customer_name": case.get("customer_name"),
|
|
"contact_name": " ".join(filter(None, [data.get("contact", {}).get("first_name"), data.get("contact", {}).get("last_name")])) if data.get("contact") else None,
|
|
"status": case.get("status"), "vtiger_status": data["status_value"],
|
|
"priority": case.get("priority"), "vtiger_priority": data["priority_value"],
|
|
"time_entries": len(data["times"]),
|
|
"time_hours": str(sum((Decimal(row["minutes"]) / Decimal("60") for row in data["times"]), Decimal("0"))),
|
|
"time_rows": [{"id": row["id"], "date": str(row.get("worked_date") or ""), "description": _vtiger_plain_text(row.get("description")), "technician": row["technician_name"], "hours": str(Decimal(row["minutes"]) / Decimal("60")), "billable": row["export_billable"]} for row in data["times"]],
|
|
"pending_time_count": data["pending_time_count"],
|
|
"already_exported": bool(mapping),
|
|
"vtiger_case_id": mapping.get("vtiger_case_id") if mapping else None,
|
|
"ticket_number": mapping.get("ticket_number") if mapping else None,
|
|
"blockers": data["blockers"], "source_hash": data["source_hash"],
|
|
}
|
|
|
|
|
|
@router.post("/hub-cases/export")
|
|
async def export_hub_case_to_vtiger(
|
|
payload: HubCaseExportConfirmRequest,
|
|
request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
data = await _prepare_hub_case_export(payload.sag_id)
|
|
if data["source_hash"] != payload.source_hash:
|
|
raise HTTPException(status_code=409, detail="Sagen eller tidsregistreringer er ændret siden forhåndsvisningen. Hent en ny forhåndsvisning.")
|
|
if data["blockers"]:
|
|
raise HTTPException(status_code=409, detail={"message": "Sagen kan ikke overføres endnu.", "blockers": data["blockers"]})
|
|
|
|
conn = get_db_connection()
|
|
vtiger = get_vtiger_service()
|
|
created_case = False
|
|
created_times = 0
|
|
warnings = []
|
|
mapping = None
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute("SELECT pg_advisory_lock(1046, %s)", (payload.sag_id,))
|
|
cursor.execute("SELECT vtiger_case_id, ticket_number FROM hub_case_vtiger_exports WHERE hub_sag_id=%s FOR UPDATE", (payload.sag_id,))
|
|
mapping = cursor.fetchone()
|
|
if not mapping:
|
|
cursor.execute("SELECT 1 FROM vtiger_case_hub_imports WHERE hub_sag_id=%s", (payload.sag_id,))
|
|
if cursor.fetchone():
|
|
raise HTTPException(status_code=409, detail="Sagen findes allerede i VTiger fra en tidligere VTiger-overførsel.")
|
|
case = data["case"]
|
|
case_payload = {
|
|
"title": _vtiger_text(case.get("titel"))[:250],
|
|
"description": _vtiger_plain_text(case.get("beskrivelse")) or f"Hub sag #{payload.sag_id}",
|
|
"casestatus": data["status_value"],
|
|
"casepriority": data["priority_value"],
|
|
"parent_id": _vtiger_reference_id(case.get("account_vtiger_id")),
|
|
"contact_id": _vtiger_reference_id(data["contact"].get("vtiger_id")),
|
|
"cf_cases_hubbet": "1",
|
|
}
|
|
result = await vtiger.create_record("Cases", case_payload)
|
|
vtiger_case_id = _vtiger_reference_id(result.get("id"))
|
|
if not vtiger_case_id:
|
|
raise RuntimeError("VTiger oprettede sagen, men returnerede ikke et gyldigt sags-id.")
|
|
ticket_number = _vtiger_text(result.get("case_no") or result.get("ticket_no")) or None
|
|
cursor.execute(
|
|
"""INSERT INTO hub_case_vtiger_exports (hub_sag_id, vtiger_case_id, ticket_number, status)
|
|
VALUES (%s,%s,%s,'partial')""",
|
|
(payload.sag_id, vtiger_case_id, ticket_number),
|
|
)
|
|
conn.commit()
|
|
mapping = {"vtiger_case_id": vtiger_case_id, "ticket_number": ticket_number}
|
|
created_case = True
|
|
vtiger_case_id = mapping["vtiger_case_id"]
|
|
conn.commit()
|
|
|
|
for row in data["times"]:
|
|
cursor.execute("SELECT vtiger_timelog_id FROM hub_time_vtiger_exports WHERE hub_time_id=%s", (row["id"],))
|
|
if cursor.fetchone():
|
|
continue
|
|
try:
|
|
time_payload = _export_time_payload(row, payload.sag_id, vtiger_case_id, row["vtiger_user_id"])
|
|
time_result = await vtiger.create_record("Timelog", time_payload)
|
|
vtiger_timelog_id = _vtiger_reference_id(time_result.get("id"))
|
|
if not vtiger_timelog_id:
|
|
raise RuntimeError("VTiger oprettede tiden, men returnerede ikke et gyldigt tids-id.")
|
|
cursor.execute(
|
|
"""INSERT INTO hub_time_vtiger_exports (hub_time_id, hub_sag_id, vtiger_timelog_id)
|
|
VALUES (%s,%s,%s)""",
|
|
(row["id"], payload.sag_id, vtiger_timelog_id),
|
|
)
|
|
conn.commit()
|
|
created_times += 1
|
|
except Exception as exc:
|
|
warnings.append(f"Tidsregistrering #{row['id']}: {str(exc)[:250]}")
|
|
break
|
|
cursor.execute(
|
|
"""UPDATE hub_case_vtiger_exports SET status=%s, ticket_number=COALESCE(ticket_number,%s),
|
|
last_error=%s, updated_at=CURRENT_TIMESTAMP WHERE hub_sag_id=%s""",
|
|
("complete" if not warnings else "partial", mapping.get("ticket_number"), "; ".join(warnings) or None, payload.sag_id),
|
|
)
|
|
conn.commit()
|
|
except HTTPException:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
conn.rollback()
|
|
warnings.append(str(exc)[:300])
|
|
execute_query(
|
|
"UPDATE hub_case_vtiger_exports SET status='partial', last_error=%s, updated_at=CURRENT_TIMESTAMP WHERE hub_sag_id=%s",
|
|
("; ".join(warnings), payload.sag_id), fetch=False,
|
|
)
|
|
if not mapping:
|
|
raise HTTPException(status_code=502, detail=f"VTiger-overførslen fejlede: {str(exc)[:300]}") from exc
|
|
finally:
|
|
try:
|
|
with conn.cursor() as cursor:
|
|
cursor.execute("SELECT pg_advisory_unlock(1046, %s)", (payload.sag_id,))
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
release_db_connection(conn)
|
|
|
|
result = {
|
|
"success": True, "sag_id": payload.sag_id,
|
|
"vtiger_case_id": vtiger_case_id,
|
|
"ticket_number": mapping.get("ticket_number"),
|
|
"created_case": created_case,
|
|
"created_time_entries": created_times,
|
|
"complete": not warnings,
|
|
"warning": "; ".join(warnings) or None,
|
|
}
|
|
audit(
|
|
request=request, current_user=current_user, action="hub_case_exported_to_vtiger",
|
|
entity_type="case", entity_id=payload.sag_id,
|
|
new_value={key: value for key, value in result.items() if key not in {"warning"}},
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/items/{item_id}/rematch")
|
|
async def rematch(
|
|
item_id: int, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
result = match_item(item_id)
|
|
audit(
|
|
request=request, current_user=current_user, action="rematched", entity_type=item["entity_type"],
|
|
entity_id=item["source_record_id"], session_id=item["session_id"], item_id=item_id,
|
|
old_value=item, new_value=result, source_hash_value=item["source_hash"],
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/sessions/{session_id}/rematch")
|
|
async def rematch_session(
|
|
session_id: int, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.import")),
|
|
):
|
|
ensure_writable(session_id)
|
|
rows = execute_query(
|
|
"""
|
|
SELECT id FROM migration_center_session_items
|
|
WHERE session_id=%s AND lock_status IN ('unlocked','lock_failed')
|
|
ORDER BY id
|
|
""",
|
|
(session_id,),
|
|
) or []
|
|
counts = {"processed": 0, "customers_mapped": 0, "matches_found": 0}
|
|
for row in rows:
|
|
result = match_item(int(row["id"]))
|
|
counts["processed"] += 1
|
|
if result.get("hub_customer_id"):
|
|
counts["customers_mapped"] += 1
|
|
if result.get("match_status") == "match_found":
|
|
counts["matches_found"] += 1
|
|
audit(
|
|
request=request, current_user=current_user, action="session_rematched",
|
|
entity_type="session", entity_id=session_id, session_id=session_id, new_value=counts,
|
|
)
|
|
return counts
|
|
|
|
|
|
@router.post("/items/{item_id}/customers")
|
|
async def create_customer(
|
|
item_id: int, payload: CustomerCreate, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.create")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
duplicate = execute_query_single(
|
|
"""
|
|
SELECT id, name FROM customers
|
|
WHERE deleted_at IS NULL AND (
|
|
(%s IS NOT NULL AND NULLIF(TRIM(cvr_number),'') = NULLIF(TRIM(%s),''))
|
|
OR LOWER(name) = LOWER(%s)
|
|
) LIMIT 1
|
|
""",
|
|
(payload.cvr_number, payload.cvr_number, payload.name),
|
|
)
|
|
if duplicate:
|
|
raise HTTPException(status_code=409, detail={"message": "Mulig dublet fundet", "customer": duplicate})
|
|
email_domain = payload.email.split("@", 1)[1].lower() if payload.email and "@" in payload.email else None
|
|
conn = get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO customers (name, cvr_number, email, email_domain, country, is_active, economic_customer_number)
|
|
VALUES (%s,%s,%s,%s,'DK',TRUE,%s) RETURNING id, name, cvr_number, email
|
|
""",
|
|
(payload.name, payload.cvr_number, payload.email, email_domain, payload.customer_no),
|
|
)
|
|
customer = dict(cursor.fetchone())
|
|
cursor.execute(
|
|
"UPDATE migration_center_session_items SET hub_customer_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
|
(customer["id"], item_id),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
release_db_connection(conn)
|
|
audit(
|
|
request=request, current_user=current_user, action="customer_created", entity_type="customer",
|
|
entity_id=customer["id"], session_id=item["session_id"], item_id=item_id, new_value=customer,
|
|
source_hash_value=item["source_hash"],
|
|
)
|
|
return customer
|
|
|
|
|
|
@router.post("/items/{item_id}/customer-link")
|
|
async def link_customer(
|
|
item_id: int, payload: LinkPayload, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.review")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
customer = execute_query_single("SELECT id, name FROM customers WHERE id=%s AND deleted_at IS NULL", (payload.hub_id,))
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Hub-kunden blev ikke fundet")
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items SET hub_customer_id=%s,
|
|
hub_sag_id=NULL, hub_record_id=NULL, hub_status='ready_for_creation',
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(payload.hub_id, item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="customer_linked", entity_type=item["entity_type"],
|
|
entity_id=item["source_record_id"], session_id=item["session_id"], item_id=item_id,
|
|
old_value={"hub_customer_id": item.get("hub_customer_id")}, new_value={"hub_customer_id": payload.hub_id},
|
|
source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True, "customer": customer}
|
|
|
|
|
|
@router.post("/items/{item_id}/cases")
|
|
async def create_case(
|
|
item_id: int, payload: CaseCreate, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.create")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
if not item.get("hub_customer_id"):
|
|
raise HTTPException(status_code=409, detail="Kunden skal linkes eller oprettes først")
|
|
row = execute_query_single(
|
|
"""
|
|
INSERT INTO sag_sager (titel, beskrivelse, template_key, status, customer_id, created_by_user_id)
|
|
VALUES (%s,%s,'subscription','åben',%s,%s) RETURNING id, titel, customer_id
|
|
""",
|
|
(payload.title, payload.description, item["hub_customer_id"], user_id(current_user)),
|
|
)
|
|
execute_query(
|
|
"UPDATE migration_center_session_items SET hub_sag_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
|
(row["id"], item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="case_created", entity_type="case",
|
|
entity_id=row["id"], session_id=item["session_id"], item_id=item_id, new_value=row,
|
|
source_hash_value=item["source_hash"],
|
|
)
|
|
return row
|
|
|
|
|
|
@router.post("/items/{item_id}/case-link")
|
|
async def link_case(
|
|
item_id: int, payload: LinkPayload, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.review")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
case = execute_query_single(
|
|
"SELECT id, titel, customer_id FROM sag_sager WHERE id=%s AND customer_id=%s",
|
|
(payload.hub_id, item.get("hub_customer_id")),
|
|
)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail="Sagen findes ikke på den valgte kunde")
|
|
execute_query(
|
|
"UPDATE migration_center_session_items SET hub_sag_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
|
(payload.hub_id, item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="case_linked", entity_type=item["entity_type"],
|
|
entity_id=item["source_record_id"], session_id=item["session_id"], item_id=item_id,
|
|
new_value={"hub_sag_id": payload.hub_id}, source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True, "case": case}
|
|
|
|
|
|
@router.get("/items/{item_id}/preflight")
|
|
async def preflight(
|
|
item_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.create")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
blockers = []
|
|
if not item.get("hub_customer_id"):
|
|
blockers.append("Kunden er ikke linket")
|
|
if not item.get("hub_sag_id"):
|
|
blockers.append("Sagen er ikke linket")
|
|
if item.get("hub_record_id") or item["hub_status"] in {"created_in_hub", "linked_to_existing"}:
|
|
blockers.append("Posten er allerede oprettet eller linket")
|
|
duplicate = execute_query_single(
|
|
"SELECT id FROM sag_subscriptions WHERE migration_source_item_id=%s", (item_id,)
|
|
)
|
|
if duplicate:
|
|
blockers.append(f"Kildeposten er allerede oprettet som abonnement {duplicate['id']}")
|
|
if item.get("hub_sag_id"):
|
|
case = execute_query_single(
|
|
"SELECT id FROM sag_sager WHERE id=%s AND customer_id=%s",
|
|
(item["hub_sag_id"], item.get("hub_customer_id")),
|
|
)
|
|
if not case:
|
|
blockers.append("Den valgte sag tilhører ikke kunden")
|
|
preview = {
|
|
"customer": item.get("customer_name"), "customer_no": item.get("customer_no"),
|
|
"product": item.get("product_name"), "product_code": item.get("product_code"),
|
|
"amount": item.get("amount"), "quantity": item.get("quantity"),
|
|
"frequency": _hub_interval(item.get("billing_frequency")),
|
|
"start_date": item.get("period_from") or item.get("invoice_date") or date.today(),
|
|
"end_date": item.get("period_to"), "source": item.get("source_system"),
|
|
"source_record_id": item.get("source_record_id"),
|
|
"hub_customer_id": item.get("hub_customer_id"), "hub_sag_id": item.get("hub_sag_id"),
|
|
}
|
|
return {
|
|
"ready": not blockers, "blockers": blockers, "preview": preview,
|
|
"token": preflight_token(item, current_user) if not blockers else None,
|
|
}
|
|
|
|
|
|
@router.post("/items/{item_id}/create-in-hub")
|
|
async def create_in_hub(
|
|
item_id: int, payload: CreatePayload, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.create")),
|
|
):
|
|
conn = get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
item = _load_item(item_id, for_update=True, cursor=cursor)
|
|
_ensure_item_mutable(item)
|
|
verify_preflight(payload.preflight_token, item, current_user)
|
|
if not item.get("hub_customer_id") or not item.get("hub_sag_id"):
|
|
raise HTTPException(status_code=409, detail="Kunde og sag skal være valgt")
|
|
if item.get("hub_record_id") or item["hub_status"] in {"created_in_hub", "linked_to_existing"}:
|
|
return {"success": True, "idempotent": True, "hub_record_id": item.get("hub_record_id")}
|
|
cursor.execute(
|
|
"SELECT id FROM sag_subscriptions WHERE migration_source_item_id=%s", (item_id,)
|
|
)
|
|
duplicate = cursor.fetchone()
|
|
if duplicate:
|
|
cursor.execute(
|
|
"""
|
|
UPDATE migration_center_session_items SET hub_record_id=%s, hub_status='created_in_hub',
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(duplicate["id"], item_id),
|
|
)
|
|
conn.commit()
|
|
return {"success": True, "idempotent": True, "hub_record_id": duplicate["id"]}
|
|
start_date = item.get("period_from") or item.get("invoice_date") or date.today()
|
|
interval = _hub_interval(item.get("billing_frequency"))
|
|
amount = Decimal(str(item.get("amount") or 0))
|
|
quantity = Decimal(str(item.get("quantity") or 1))
|
|
unit_price = amount / quantity if quantity else amount
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO sag_subscriptions
|
|
(sag_id, customer_id, product_name, billing_interval, billing_day, price,
|
|
start_date, end_date, period_start, next_invoice_date, status, notes,
|
|
created_by_user_id, migration_source_item_id)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'draft',%s,%s,%s)
|
|
RETURNING id, subscription_number
|
|
""",
|
|
(
|
|
item["hub_sag_id"], item["hub_customer_id"], item["product_name"], interval,
|
|
min(max(start_date.day, 1), 31), amount, start_date, item.get("period_to"),
|
|
start_date, start_date, f"Manuelt oprettet via migreringscenter fra {item['source_system']} "
|
|
f"{item['source_record_id']} (idempotency {payload.idempotency_key})",
|
|
user_id(current_user), item_id,
|
|
),
|
|
)
|
|
subscription = dict(cursor.fetchone())
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO sag_subscription_items
|
|
(subscription_id, line_no, description, quantity, unit_price, line_total, period_from, period_to)
|
|
VALUES (%s,1,%s,%s,%s,%s,%s,%s)
|
|
""",
|
|
(
|
|
subscription["id"], item["product_name"], quantity or Decimal("1"),
|
|
unit_price, amount, item.get("period_from"), item.get("period_to"),
|
|
),
|
|
)
|
|
cursor.execute(
|
|
"""
|
|
UPDATE migration_center_session_items
|
|
SET hub_record_id=%s, hub_status='created_in_hub', approval_status='approved',
|
|
creation_idempotency_key=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(subscription["id"], payload.idempotency_key, item_id),
|
|
)
|
|
conn.commit()
|
|
except HTTPException:
|
|
conn.rollback()
|
|
raise
|
|
except Exception as exc:
|
|
conn.rollback()
|
|
audit(
|
|
request=request, current_user=current_user, action="create_in_hub",
|
|
entity_type="session_item", entity_id=item_id, item_id=item_id, success=False,
|
|
error_message=str(exc),
|
|
)
|
|
raise HTTPException(status_code=500, detail=f"Oprettelsen fejlede uden lokal statusændring: {exc}") from exc
|
|
finally:
|
|
release_db_connection(conn)
|
|
audit(
|
|
request=request, current_user=current_user, action="created_in_hub",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
|
|
item_id=item_id, old_value={"hub_status": item["hub_status"]},
|
|
new_value={"hub_status": "created_in_hub", "hub_record_id": subscription["id"]},
|
|
source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True, "idempotent": False, "hub_record_id": subscription["id"], "subscription": subscription}
|
|
|
|
|
|
@router.post("/items/{item_id}/hub-link")
|
|
async def link_hub_record(
|
|
item_id: int, payload: LinkPayload, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.review")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
subscription = execute_query_single(
|
|
"SELECT id, subscription_number, customer_id FROM sag_subscriptions WHERE id=%s AND customer_id=%s",
|
|
(payload.hub_id, item.get("hub_customer_id")),
|
|
)
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="Abonnementet findes ikke på den valgte kunde")
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items SET hub_record_id=%s, hub_status='linked_to_existing',
|
|
approval_status='approved', updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(payload.hub_id, item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="linked_to_existing",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
|
|
item_id=item_id, new_value={"hub_record_id": payload.hub_id}, source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True, "subscription": subscription}
|
|
|
|
|
|
@router.post("/items/{item_id}/verify")
|
|
async def verify_item(
|
|
item_id: int, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.review")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
if item["hub_status"] not in {"created_in_hub", "linked_to_existing"}:
|
|
raise HTTPException(status_code=409, detail="Posten skal oprettes eller linkes før kontrol")
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items
|
|
SET approval_status='verified', hub_status='verified', verified_at=CURRENT_TIMESTAMP,
|
|
verified_by_user_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(user_id(current_user), item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="marked_verified",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
|
|
item_id=item_id, source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/items/{item_id}/ignore")
|
|
async def ignore_item(
|
|
item_id: int, payload: ReasonPayload, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.review")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items SET approval_status='ignored', ignore_reason=%s,
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(payload.reason, item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="ignored",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
|
|
item_id=item_id, new_value={"reason": payload.reason}, source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True}
|
|
|
|
|
|
@router.put("/items/{item_id}/note")
|
|
async def update_note(
|
|
item_id: int, payload: NotePayload, request: Request,
|
|
current_user: dict = Depends(require_permission("migration_center.review")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
execute_query(
|
|
"UPDATE migration_center_session_items SET manual_note=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
|
(payload.note, item_id), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="note_updated",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
|
|
item_id=item_id, old_value={"note": item.get("manual_note")}, new_value={"note": payload.note},
|
|
source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/items/{item_id}/lock")
|
|
async def lock_item(
|
|
item_id: int, request: Request,
|
|
current_user: dict = Depends(require_any_permission("migration_center.lock", "migration_center.retry")),
|
|
):
|
|
item = _load_item(item_id)
|
|
_ensure_item_mutable(item)
|
|
if item["approval_status"] not in {"verified", "ignored"}:
|
|
raise HTTPException(status_code=409, detail="Posten skal være verificeret eller ignoreret før låsning")
|
|
if item["match_status"] in {"conflict", "source_changed"}:
|
|
raise HTTPException(status_code=409, detail="Åben konflikt eller kildeændring blokerer låsning")
|
|
# Economic is read-only and has no external lock target. CRM records require configured custom fields.
|
|
lock_field = None
|
|
if item["source_system"] in {"vtiger", "simply"}:
|
|
lock_field = (
|
|
getattr(settings, "MIGRATION_CENTER_VTIGER_LOCK_FIELD", "")
|
|
if item["source_system"] == "vtiger"
|
|
else getattr(settings, "MIGRATION_CENTER_SIMPLY_LOCK_FIELD", "")
|
|
)
|
|
if not lock_field:
|
|
raise HTTPException(status_code=409, detail="CRM-låsefeltet er ikke konfigureret")
|
|
# Persist pending before any remote call so a crash cannot look like a completed lock.
|
|
pending = execute_query_single(
|
|
"""
|
|
INSERT INTO migration_center_lock_operations
|
|
(session_item_id, status, external_system, request_payload, requested_by_user_id)
|
|
VALUES (%s,'pending',%s,%s,%s) RETURNING id
|
|
""",
|
|
(
|
|
item_id, item["source_system"],
|
|
Json({"source_record_id": item["source_record_id"], "field": lock_field}),
|
|
user_id(current_user),
|
|
),
|
|
)
|
|
execute_query(
|
|
"UPDATE migration_center_session_items SET lock_status='locking_pending', updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
|
(item_id,), fetch=False,
|
|
)
|
|
if lock_field:
|
|
try:
|
|
lock_values = {
|
|
lock_field: "1",
|
|
"hub_locked_at": date.today().isoformat(),
|
|
"hub_transfer_status": "locked",
|
|
}
|
|
if item["source_system"] == "vtiger":
|
|
await get_vtiger_service().update_subscription(item["source_record_id"], lock_values)
|
|
else:
|
|
async with SimplyCRMService() as service:
|
|
await service._ensure_session()
|
|
async with service.session.post(
|
|
f"{service.base_url}/webservice.php",
|
|
data={
|
|
"operation": "update",
|
|
"sessionName": service.session_name,
|
|
"elementType": "SalesOrder",
|
|
"element": json.dumps({"id": item["source_record_id"], **lock_values}),
|
|
},
|
|
) as response:
|
|
result = await response.json()
|
|
if not response.ok or not result.get("success"):
|
|
raise RuntimeError(str(result.get("error") or f"HTTP {response.status}"))
|
|
except Exception as exc:
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_session_items SET lock_status='lock_failed', updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=%s
|
|
""",
|
|
(item_id,), fetch=False,
|
|
)
|
|
execute_query(
|
|
"""
|
|
UPDATE migration_center_lock_operations SET status='failed', error_message=%s,
|
|
completed_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(str(exc), pending["id"]), fetch=False,
|
|
)
|
|
audit(
|
|
request=request, current_user=current_user, action="lock_failed",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"],
|
|
session_id=item["session_id"], item_id=item_id, success=False,
|
|
error_message=str(exc), source_hash_value=item["source_hash"],
|
|
)
|
|
raise HTTPException(status_code=502, detail=f"Ekstern låsning fejlede: {exc}") from exc
|
|
conn = get_db_connection()
|
|
try:
|
|
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
|
if item.get("hub_record_id"):
|
|
cursor.execute(
|
|
"""
|
|
UPDATE sag_subscriptions SET migration_locked=TRUE, migration_locked_at=CURRENT_TIMESTAMP,
|
|
migration_locked_by_user_id=%s WHERE id=%s
|
|
""",
|
|
(user_id(current_user), item["hub_record_id"]),
|
|
)
|
|
cursor.execute(
|
|
"""
|
|
UPDATE migration_center_session_items SET lock_status='locked', locked_at=CURRENT_TIMESTAMP,
|
|
locked_by_user_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s
|
|
""",
|
|
(user_id(current_user), item_id),
|
|
)
|
|
cursor.execute(
|
|
"""
|
|
UPDATE migration_center_lock_operations SET status='succeeded', completed_at=CURRENT_TIMESTAMP
|
|
WHERE id=%s
|
|
""",
|
|
(pending["id"],),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
release_db_connection(conn)
|
|
audit(
|
|
request=request, current_user=current_user, action="locked",
|
|
entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
|
|
item_id=item_id, new_value={"lock_status": "locked"}, source_hash_value=item["source_hash"],
|
|
)
|
|
return {"success": True, "lock_status": "locked"}
|
|
|
|
|
|
@router.post("/sessions/{session_id}/customers/{customer_id}/lock")
|
|
async def lock_customer_items(
|
|
session_id: int,
|
|
customer_id: int,
|
|
request: Request,
|
|
current_user: dict = Depends(require_any_permission("migration_center.lock", "migration_center.retry")),
|
|
):
|
|
ensure_writable(session_id)
|
|
rows = execute_query(
|
|
f"""
|
|
SELECT * FROM migration_center_session_items
|
|
WHERE session_id=%s AND hub_customer_id=%s
|
|
AND {EXCLUDED_INVOICE_LINE_SQL}
|
|
AND {subscription_like_item_sql()}
|
|
ORDER BY id
|
|
""",
|
|
(session_id, customer_id),
|
|
) or []
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Ingen poster blev fundet for kunden")
|
|
blockers = [
|
|
row["id"] for row in rows
|
|
if row["lock_status"] != "locked" and (
|
|
row["approval_status"] not in {"verified", "ignored"}
|
|
or row["match_status"] in {"conflict", "source_changed"}
|
|
)
|
|
]
|
|
if blockers:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"message": "Alle poster skal være behandlet uden åbne konflikter", "item_ids": blockers},
|
|
)
|
|
results = []
|
|
for row in rows:
|
|
if row["lock_status"] == "locked":
|
|
continue
|
|
try:
|
|
result = await lock_item(int(row["id"]), request, current_user)
|
|
results.append({"item_id": row["id"], **result})
|
|
except HTTPException as exc:
|
|
audit(
|
|
request=request, current_user=current_user, action="customer_lock_partial",
|
|
entity_type="customer", entity_id=customer_id, session_id=session_id,
|
|
success=False, error_message=str(exc.detail), new_value={"completed": results},
|
|
)
|
|
raise
|
|
audit(
|
|
request=request, current_user=current_user, action="customer_locked",
|
|
entity_type="customer", entity_id=customer_id, session_id=session_id,
|
|
new_value={"items": [row["item_id"] for row in results]},
|
|
)
|
|
return {"success": True, "locked": results}
|
|
|
|
|
|
@router.get("/sessions/{session_id}/audit")
|
|
async def audit_log(
|
|
session_id: int, limit: int = Query(200, ge=1, le=1000),
|
|
current_user: dict = Depends(require_permission("migration_center.view")),
|
|
):
|
|
return execute_query(
|
|
"""
|
|
SELECT a.*, COALESCE(u.full_name,u.username) AS performed_by
|
|
FROM migration_center_audit_log a
|
|
LEFT JOIN users u ON u.user_id=a.performed_by_user_id
|
|
WHERE a.session_id=%s ORDER BY a.performed_at DESC LIMIT %s
|
|
""",
|
|
(session_id, limit),
|
|
) or []
|
|
|
|
|
|
@router.get("/sessions/{session_id}/report.csv")
|
|
async def export_report(
|
|
session_id: int,
|
|
current_user: dict = Depends(require_permission("migration_center.export")),
|
|
):
|
|
content = "\ufeff" + report_csv(session_id)
|
|
return Response(
|
|
content=content, media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="migration-session-{session_id}.csv"'},
|
|
)
|