2026-07-28 14:18:24 +02:00
|
|
|
"""HTTP API for the manual migration centre."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
2026-09-24 09:12:25 +02:00
|
|
|
import re
|
|
|
|
|
import hashlib
|
|
|
|
|
import math
|
|
|
|
|
from html.parser import HTMLParser
|
|
|
|
|
from datetime import date, datetime, time as datetime_time, timedelta
|
2026-07-28 14:18:24 +02:00
|
|
|
from decimal import Decimal
|
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
|
|
|
from fastapi.responses import Response
|
2026-09-24 09:12:25 +02:00
|
|
|
from pydantic import BaseModel, Field, field_validator
|
2026-07-28 14:18:24 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 09:12:25 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 09:12:25 +02:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 14:18:24 +02:00
|
|
|
@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"'},
|
|
|
|
|
)
|