bmc_hub/app/modules/migration_center/backend/service.py

642 lines
28 KiB
Python
Raw Permalink Normal View History

"""Core repositories and workflows for the manual migration centre."""
from __future__ import annotations
import csv
import hashlib
import io
import json
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from difflib import SequenceMatcher
from typing import Any, Dict, Iterable, List, Optional
import jwt
from fastapi import HTTPException, Request
from psycopg2.extras import Json, RealDictCursor
from app.core.config import settings
from app.core.database import (
execute_query,
execute_query_single,
get_db_connection,
release_db_connection,
)
MUTABLE_LOCK_STATES = {"unlocked", "lock_failed"}
def subscription_like_item_sql(alias: str = "migration_center_session_items") -> str:
"""Fast predicate using the classification refreshed when snapshots change."""
p = f"{alias}." if alias else ""
return f"{p}subscription_like=TRUE"
def refresh_subscription_relevance(session_id: int) -> Dict[str, int]:
"""Classify imported invoice lines once so all views share a fast, consistent filter."""
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=TRUE, subscription_relevance_reason='crm_subscription'
WHERE session_id=%s AND source_system<>'economic'
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=FALSE, subscription_relevance_reason='one_off'
WHERE session_id=%s AND source_system='economic'
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_relevance_reason='excluded_charge'
WHERE session_id=%s AND source_system='economic'
AND (
POSITION('gebyr' IN LOWER(COALESCE(product_name,'')))>0
OR POSITION('fragt' IN LOWER(COALESCE(product_name,'')))>0
OR POSITION('porto' IN LOWER(COALESCE(product_name,'')))>0
)
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=TRUE, subscription_relevance_reason='subscription_keyword'
WHERE session_id=%s AND source_system='economic'
AND subscription_relevance_reason<>'excluded_charge'
AND LOWER(COALESCE(product_name,'')) ~
'(abonnement|subscription|måned|kvartal|årlig|licens|license|fiber|internet|bredbånd|hosting|domæne|domain|cloud|microsoft|office[ ]?365|backup|supportaftale|driftsaftale|udlejning|leje|telefoni|simkort|eset)'
""",
(session_id,), fetch=False,
)
execute_query(
"""
WITH recurring AS (
SELECT
COALESCE(NULLIF(customer_no,''),source_customer_id,customer_name) AS customer_key,
COALESCE(NULLIF(product_code,''),LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g'))) AS product_key
FROM migration_center_session_items
WHERE session_id=%s AND source_system='economic'
AND subscription_relevance_reason<>'excluded_charge'
AND invoice_date >= (DATE_TRUNC('month',CURRENT_DATE)-INTERVAL '12 months')::date
GROUP BY 1,2
HAVING COUNT(DISTINCT invoice_no)>=2
AND COUNT(DISTINCT DATE_TRUNC('month',invoice_date))>=2
)
UPDATE migration_center_session_items item
SET subscription_like=TRUE, subscription_relevance_reason='recurring_invoice'
FROM recurring
WHERE item.session_id=%s AND item.source_system='economic'
AND item.subscription_relevance_reason<>'excluded_charge'
AND COALESCE(NULLIF(item.customer_no,''),item.source_customer_id,item.customer_name)
IS NOT DISTINCT FROM recurring.customer_key
AND COALESCE(NULLIF(item.product_code,''),LOWER(REGEXP_REPLACE(item.product_name,'\\s+',' ','g')))
IS NOT DISTINCT FROM recurring.product_key
""",
(session_id, session_id), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items item
SET subscription_like=TRUE, subscription_relevance_reason='existing_subscription'
WHERE item.session_id=%s AND item.source_system='economic'
AND item.subscription_relevance_reason<>'excluded_charge'
AND EXISTS (
SELECT 1 FROM sag_subscriptions subscription
WHERE subscription.customer_id=item.hub_customer_id
AND subscription.status<>'cancelled'
AND (
LOWER(TRIM(COALESCE(subscription.product_name,'')))=
LOWER(TRIM(COALESCE(item.product_name,'')))
OR ABS(COALESCE(subscription.price,0)-COALESCE(item.amount,0))<=0.01
)
)
""",
(session_id,), fetch=False,
)
row = execute_query_single(
"""
SELECT COUNT(*) FILTER (WHERE subscription_like) AS visible,
COUNT(*) FILTER (WHERE NOT subscription_like) AS hidden
FROM migration_center_session_items WHERE session_id=%s
""",
(session_id,),
)
return {"visible": int(row["visible"]), "hidden": int(row["hidden"])}
def json_value(value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {str(k): json_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [json_value(v) for v in value]
return value
def snapshot_hash(payload: Dict[str, Any]) -> str:
packed = json.dumps(json_value(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(packed.encode("utf-8")).hexdigest()
def user_id(current_user: Dict[str, Any]) -> Optional[int]:
value = current_user.get("id") or current_user.get("user_id")
return int(value) if value is not None else None
def audit(
*,
request: Request,
current_user: Dict[str, Any],
action: str,
entity_type: str,
entity_id: Any = None,
session_id: Optional[int] = None,
item_id: Optional[int] = None,
old_value: Any = None,
new_value: Any = None,
source_hash_value: Optional[str] = None,
success: bool = True,
error_message: Optional[str] = None,
) -> None:
execute_query(
"""
INSERT INTO migration_center_audit_log
(session_id, session_item_id, entity_type, entity_id, action, old_value, new_value,
source_hash, performed_by_user_id, ip_address, success, error_message)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
session_id, item_id, entity_type, str(entity_id) if entity_id is not None else None,
action, Json(json_value(old_value)) if old_value is not None else None,
Json(json_value(new_value)) if new_value is not None else None,
source_hash_value, user_id(current_user),
request.client.host if request.client else None, success, error_message,
),
fetch=False,
)
def ensure_writable(session_id: Optional[int] = None) -> None:
if getattr(settings, "MIGRATION_CENTER_READ_ONLY", False):
raise HTTPException(status_code=423, detail="Migreringscenteret er i read-only tilstand")
if session_id:
session = execute_query_single(
"SELECT read_only, status FROM migration_center_sessions WHERE id = %s", (session_id,)
)
if not session:
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
if session["read_only"] or session["status"] in {"completed", "archived"}:
raise HTTPException(status_code=423, detail="Kontrolsessionen er skrivebeskyttet")
class EconomicSnapshotRepository:
"""Read-only access to the Invoice Error Finder invoice snapshot."""
@staticmethod
def latest_run() -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT r.id, r.source_type, r.started_at, r.completed_at, r.status,
r.records_imported, r.records_failed, COUNT(i.id) AS invoice_count
FROM invoice_error_finder_import_runs r
JOIN invoice_error_finder_economic_invoices i ON i.import_run_id = r.id
WHERE r.source_type = 'economic_invoices'
AND r.status IN ('success', 'partial')
AND r.completed_at IS NOT NULL
GROUP BY r.id
HAVING COUNT(i.id) > 0
ORDER BY r.completed_at DESC, r.id DESC
LIMIT 1
"""
)
@staticmethod
def lines(run_id: int) -> List[Dict[str, Any]]:
# DISTINCT ON makes the source identity stable even if an API endpoint repeats a line.
return execute_query(
"""
SELECT DISTINCT ON (
COALESCE(i.source_invoice_number, i.id::text),
i.source_type,
COALESCE(l.line_number, l.id)
)
i.id AS invoice_id, i.source_invoice_number, i.source_type, i.customer_number,
i.customer_name, i.invoice_date, i.currency, i.source_raw AS invoice_raw,
l.id AS invoice_line_id, l.line_number, l.product_number, l.product_name,
l.description, l.quantity, l.unit_price, l.line_net_amount, l.source_raw AS line_raw
FROM invoice_error_finder_economic_invoices i
JOIN invoice_error_finder_economic_invoice_lines l ON l.invoice_id = i.id
WHERE i.import_run_id = %s
AND i.invoice_date >= (DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '12 months')::date
AND i.invoice_date <= CURRENT_DATE
AND LOWER(
COALESCE(l.product_name,'') || ' ' ||
COALESCE(l.description,'') || ' ' ||
COALESCE(l.product_number,'')
) NOT SIMILAR TO '%%(gebyr|fragt|porto)%%'
ORDER BY
COALESCE(i.source_invoice_number, i.id::text),
i.source_type,
COALESCE(l.line_number, l.id),
l.id DESC
""",
(run_id,),
) or []
def attach_economic_snapshot(session_id: int, run: Dict[str, Any]) -> Dict[str, int]:
"""Attach the selected usable 13-month IEF snapshot to an explicit session."""
counts = {"created": 0, "unchanged": 0}
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
UPDATE migration_center_sessions
SET economic_import_run_id=%s, economic_snapshot_at=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s
""",
(run["id"], run["completed_at"], session_id),
)
for row in EconomicSnapshotRepository.lines(run["id"]):
item = _normalize_economic_line(dict(row))
cursor.execute(
"""
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, invoice_no, invoice_date,
source_payload, source_hash)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
RETURNING id
""",
(
session_id, item["entity_type"], 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["invoice_no"],
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
),
)
inserted = cursor.fetchone()
counts["created" if inserted else "unchanged"] += 1
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
return counts
def _normalize_economic_line(row: Dict[str, Any]) -> Dict[str, Any]:
source_id = f"{row.get('source_type')}:{row.get('source_invoice_number') or row['invoice_id']}:{row.get('line_number') or row['invoice_line_id']}"
raw = {"invoice": row.get("invoice_raw") or {}, "line": row.get("line_raw") or {}}
normalized = {
"entity_type": "invoice_line",
"source_system": "economic",
"source_record_id": source_id,
"source_customer_id": str(row.get("customer_number") or ""),
"customer_no": str(row.get("customer_number") or ""),
"customer_name": str(row.get("customer_name") or "")[:255] or None,
"product_code": str(row.get("product_number") or "")[:100] or None,
"product_name": str(row.get("product_name") or row.get("description") or "Fakturalinje")[:500],
"amount": row.get("line_net_amount") or 0,
"quantity": row.get("quantity") or 1,
"billing_frequency": None,
"period_from": row.get("invoice_date"),
"period_to": None,
"invoice_no": row.get("source_invoice_number"),
"invoice_date": row.get("invoice_date"),
"source_payload": raw,
}
normalized["source_hash"] = snapshot_hash(normalized)
return normalized
def _customer_candidates(item: Dict[str, Any]) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT id, name, cvr_number, email, email_domain, economic_customer_number
FROM customers
WHERE deleted_at IS NULL
ORDER BY id
"""
) or []
source_no = str(item.get("customer_no") or "").strip().lower()
source_name = str(item.get("customer_name") or "").strip().lower()
source_cvr = str((item.get("source_payload") or {}).get("customer_cvr") or "").replace(" ", "").strip()
candidates = []
for row in rows:
rules: List[str] = []
score = 0.0
if source_no and str(row.get("economic_customer_number") or "").strip().lower() == source_no:
score += 0.75
rules.append("Kundenummer stemmer")
if source_cvr and str(row.get("cvr_number") or "").replace(" ", "").strip() == source_cvr:
score += 0.90
rules.append("CVR stemmer")
name_ratio = SequenceMatcher(None, source_name, str(row.get("name") or "").strip().lower()).ratio()
if source_name and name_ratio >= 0.70:
score += min(0.25, name_ratio * 0.25)
rules.append(f"Firmanavn ligner ({round(name_ratio * 100)} %)")
if score:
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
return sorted(candidates, key=lambda value: value["score"], reverse=True)
def _resolve_source_customer(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve authoritative CRM mappings before heuristic customer matching."""
source_id = str(item.get("source_customer_id") or "").strip()
if not source_id:
return None
if item.get("source_system") == "simply":
row = execute_query_single(
"""
SELECT source_customer_name AS customer_name, source_customer_cvr AS cvr,
hub_customer_id
FROM simply_subscription_staging
WHERE source_account_id=%s
ORDER BY (hub_customer_id IS NOT NULL) DESC, updated_at DESC, id DESC
LIMIT 1
""",
(source_id,),
)
if row:
result = dict(row)
result["rule"] = "Eksisterende Simply-kundemapping"
return result
if item.get("source_system") == "vtiger":
row = execute_query_single(
"""
SELECT id AS hub_customer_id, name AS customer_name, cvr_number AS cvr
FROM customers WHERE vtiger_id=%s AND deleted_at IS NULL LIMIT 1
""",
(source_id,),
)
if row:
result = dict(row)
result["rule"] = "Vtiger-konto-id stemmer"
return result
return None
def _subscription_candidates(item: Dict[str, Any], customer_id: Optional[int]) -> List[Dict[str, Any]]:
if not customer_id:
return []
rows = execute_query(
"""
SELECT id, product_name, price, billing_interval, start_date, end_date
FROM sag_subscriptions
WHERE customer_id = %s AND status <> 'cancelled'
ORDER BY updated_at DESC, id DESC
""",
(customer_id,),
) or []
source_name = str(item.get("product_name") or "").strip().lower()
source_amount = Decimal(str(item.get("amount") or 0))
candidates = []
for row in rows:
rules: List[str] = []
score = 0.0
ratio = SequenceMatcher(None, source_name, str(row.get("product_name") or "").strip().lower()).ratio()
if ratio >= 0.55:
score += ratio * 0.55
rules.append(f"Produktnavn ligner ({round(ratio * 100)} %)")
hub_amount = Decimal(str(row.get("price") or 0))
if abs(source_amount - hub_amount) <= Decimal("0.01"):
score += 0.35
rules.append("Beløb stemmer")
elif max(abs(source_amount), Decimal("1")) and abs(source_amount - hub_amount) / max(abs(source_amount), Decimal("1")) <= Decimal("0.10"):
score += 0.15
rules.append("Beløb afviger højst 10 %")
if item.get("billing_frequency") and item["billing_frequency"] == row.get("billing_interval"):
score += 0.10
rules.append("Frekvens stemmer")
if score >= 0.35:
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
return sorted(candidates, key=lambda value: value["score"], reverse=True)
def match_item(item_id: int) -> Dict[str, Any]:
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
if not item:
raise HTTPException(status_code=404, detail="Post blev ikke fundet")
authoritative = _resolve_source_customer(item)
if authoritative:
enriched_payload = dict(item.get("source_payload") or {})
if authoritative.get("cvr"):
enriched_payload["customer_cvr"] = authoritative["cvr"]
execute_query(
"""
UPDATE migration_center_session_items
SET customer_name=COALESCE(NULLIF(%s,''),customer_name),
hub_customer_id=COALESCE(%s,hub_customer_id), source_payload=%s,
updated_at=CURRENT_TIMESTAMP
WHERE id=%s AND lock_status IN ('unlocked','lock_failed')
""",
(
authoritative.get("customer_name"), authoritative.get("hub_customer_id"),
Json(json_value(enriched_payload)), item_id,
), fetch=False,
)
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id=%s", (item_id,))
customer_matches = _customer_candidates(item)
customer_id = item.get("hub_customer_id") or (customer_matches[0]["id"] if customer_matches else None)
sub_matches = _subscription_candidates(item, customer_id)
best = sub_matches[0] if sub_matches else None
explanations = []
if authoritative:
explanations.append(authoritative["rule"])
if customer_matches:
explanations.extend(customer_matches[0]["rules"])
explanations.extend(best["rules"] if best else ["Intet sikkert abonnement-match"])
confidence = best["score"] if best else (customer_matches[0]["score"] * 0.4 if customer_matches else 0)
status = "match_found" if best and confidence >= 0.60 else ("manual_review" if confidence else "no_match")
hub_status = item["hub_status"]
if hub_status == "not_created" and not best:
hub_status = "ready_for_creation" if customer_id else "not_created"
execute_query(
"""
UPDATE migration_center_session_items
SET hub_customer_id = COALESCE(hub_customer_id, %s), suggested_hub_record_id = %s,
match_confidence = %s, match_explanation = %s, match_status = %s,
hub_status = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND lock_status IN ('unlocked','lock_failed')
""",
(customer_id, best["id"] if best else None, confidence, Json(explanations), status, hub_status, item_id),
fetch=False,
)
execute_query(
"DELETE FROM migration_center_matches WHERE session_item_id=%s AND approved IS NULL",
(item_id,), fetch=False,
)
for candidate in customer_matches[:5]:
execute_query(
"""
INSERT INTO migration_center_matches
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
VALUES (%s,'customer',%s,%s,%s)
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
""",
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
fetch=False,
)
for candidate in sub_matches[:5]:
execute_query(
"""
INSERT INTO migration_center_matches
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
VALUES (%s,'subscription',%s,%s,%s)
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
""",
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
fetch=False,
)
return execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
def create_session(name: str, current_user: Dict[str, Any], request: Request) -> Dict[str, Any]:
ensure_writable()
run = EconomicSnapshotRepository.latest_run()
if not run:
raise HTTPException(
status_code=409,
detail="Ingen færdig e-conomic-import findes i Faktura-fejl-finder",
)
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
INSERT INTO migration_center_sessions
(name, status, economic_import_run_id, economic_snapshot_at, created_by_user_id)
VALUES (%s, 'active', %s, %s, %s) RETURNING *
""",
(name.strip(), run["id"], run["completed_at"], user_id(current_user)),
)
session = dict(cursor.fetchone())
for row in EconomicSnapshotRepository.lines(run["id"]):
item = _normalize_economic_line(dict(row))
if item["source_customer_id"]:
customer_payload = {
"source_customer_id": item["source_customer_id"],
"customer_no": item["customer_no"],
"customer_name": item["customer_name"] or item["source_customer_id"],
}
cursor.execute(
"""
INSERT INTO migration_center_source_customers
(source_system, source_customer_id, customer_no, customer_name, raw_payload, snapshot_hash)
VALUES ('economic',%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_customer_id"], item["customer_no"], customer_payload["customer_name"],
Json(customer_payload), snapshot_hash(customer_payload),
),
)
cursor.execute(
"""
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, invoice_no, invoice_date,
source_payload, source_hash)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
""",
(
session["id"], item["entity_type"], 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["invoice_no"],
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
audit(
request=request, current_user=current_user, action="session_created",
entity_type="session", entity_id=session["id"], session_id=session["id"], new_value=session,
)
# Matching is deliberately outside the snapshot transaction and can be re-run safely.
ids = execute_query("SELECT id FROM migration_center_session_items WHERE session_id = %s", (session["id"],)) or []
for row in ids:
match_item(int(row["id"]))
refresh_subscription_relevance(int(session["id"]))
return session
def preflight_token(item: Dict[str, Any], current_user: Dict[str, Any]) -> str:
payload = {
"purpose": "migration_center_create",
"item_id": item["id"],
"source_hash": item["source_hash"],
"user_id": user_id(current_user),
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
def verify_preflight(token: str, item: Dict[str, Any], current_user: Dict[str, Any]) -> None:
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=409, detail="Preflight er udløbet eller ugyldig") from exc
if (
payload.get("purpose") != "migration_center_create"
or int(payload.get("item_id", 0)) != int(item["id"])
or payload.get("source_hash") != item["source_hash"]
or int(payload.get("user_id", 0)) != int(user_id(current_user) or 0)
):
raise HTTPException(status_code=409, detail="Kildedata eller bruger er ændret siden preflight")
def report_csv(session_id: int) -> str:
session = execute_query_single("SELECT * FROM migration_center_sessions WHERE id = %s", (session_id,))
if not session:
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
rows = execute_query(
f"""
SELECT entity_type, source_system, source_record_id, invoice_no, customer_no, customer_name,
product_code, product_name, amount, quantity, match_status, approval_status,
hub_status, lock_status, hub_customer_id, hub_sag_id, hub_record_id,
ignore_reason, verified_at, locked_at
FROM migration_center_session_items
WHERE session_id = %s
AND {subscription_like_item_sql()}
ORDER BY id
""",
(session_id,),
) or []
output = io.StringIO()
fields = list(rows[0].keys()) if rows else [
"entity_type", "source_system", "source_record_id", "match_status",
"approval_status", "hub_status", "lock_status",
]
writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({key: json_value(value) for key, value in dict(row).items()})
return output.getvalue()