release: v2.8.22
This commit is contained in:
parent
1fb5b4a191
commit
47afd41877
2
.gitignore
vendored
2
.gitignore
vendored
@ -28,4 +28,6 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
# Generated Graphify graphs and caches (keep local only)
|
||||||
|
graphify-out/
|
||||||
RELEASE_NOTES_v2.2.38.md
|
RELEASE_NOTES_v2.2.38.md
|
||||||
|
|||||||
22
MDfile/RELEASE_NOTES_v2.8.22.md
Normal file
22
MDfile/RELEASE_NOTES_v2.8.22.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# BMC Hub v2.8.22
|
||||||
|
|
||||||
|
## Arbejdsgange, sager og tid
|
||||||
|
|
||||||
|
- Tilføjer den reversible workflow-motor med kontrolleret aktivering, preview og revisionsspor for afregning.
|
||||||
|
- Udvider sagsarbejdsrummet, tidsregistrering og planlægning med tydeligere kapacitet, opgaver og opfølgning.
|
||||||
|
- Tilføjer sikker fjernstyring og forbedrede sagskommandoer uden ændring af eksisterende API-kontrakter.
|
||||||
|
|
||||||
|
## Kunder, e-mail og drift
|
||||||
|
|
||||||
|
- Forbedrer kunde- og kontaktarbejdsgange, e-mailidentitet og routing samt Drift-filtersikkerhed.
|
||||||
|
- Tilføjer planlagt onboarding/offboarding på kontaktkortet med eksplicit sagsoprettelse og audit-spor.
|
||||||
|
- Udvider hardware-intake, leverandørdata og økonomiske integrationsvisninger.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Kør migrationerne `1053` til `1076` i numerisk rækkefølge før eller sammen med opstart af versionen. Migration `1076_contact_lifecycle.sql` opretter kun nye tabeller og indeks.
|
||||||
|
|
||||||
|
## Validering
|
||||||
|
|
||||||
|
- Python: `986 passed, 103 skipped` med workflow- og remote-feature-flags slået fra i unit-testmiljøet.
|
||||||
|
- JavaScript: `86 passed`.
|
||||||
213
app/contacts/backend/lifecycle.py
Normal file
213
app/contacts/backend/lifecycle.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Additive, customer-scoped contact onboarding and offboarding API."""
|
||||||
|
from datetime import date
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.auth_dependencies import require_any_permission
|
||||||
|
from app.core.case_visibility import hidden_case_ids, require_case
|
||||||
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
read_access = require_any_permission('cases.view', 'tickets.view')
|
||||||
|
edit_access = require_any_permission('cases.edit', 'tickets.edit')
|
||||||
|
EventType = Literal['onboarding', 'offboarding']
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleCreate(BaseModel):
|
||||||
|
customer_id: int = Field(gt=0)
|
||||||
|
event_type: EventType
|
||||||
|
event_date: date
|
||||||
|
lead_days: int = Field(default=14, ge=0, le=3650)
|
||||||
|
auto_create: bool = False
|
||||||
|
template_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
responsible_user_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
parent_case_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleUpdate(BaseModel):
|
||||||
|
customer_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
event_date: Optional[date] = None
|
||||||
|
lead_days: Optional[int] = Field(default=None, ge=0, le=3650)
|
||||||
|
auto_create: Optional[bool] = None
|
||||||
|
template_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
responsible_user_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
parent_case_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _ready():
|
||||||
|
if not (execute_query_single("SELECT to_regclass('public.contact_lifecycle_events') AS relation") or {}).get('relation'):
|
||||||
|
raise HTTPException(503, detail='Onboarding/offboarding afventer databasemigration 1076')
|
||||||
|
|
||||||
|
|
||||||
|
def _contact(contact_id):
|
||||||
|
row = execute_query_single('SELECT id FROM contacts WHERE id=%s', (contact_id,))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, detail='Kontaktpersonen findes ikke')
|
||||||
|
|
||||||
|
|
||||||
|
def _membership(contact_id, customer_id):
|
||||||
|
if not execute_query_single('SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s',
|
||||||
|
(contact_id, customer_id)):
|
||||||
|
raise HTTPException(400, detail='Kunden er ikke tilknyttet kontaktpersonen')
|
||||||
|
|
||||||
|
|
||||||
|
def _has(user, *permissions):
|
||||||
|
return bool(user.get('is_superadmin') or set(permissions).intersection(user.get('permissions') or []))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(row, user):
|
||||||
|
_membership(row['contact_id'], row['customer_id'])
|
||||||
|
if row['auto_create'] and not _has(user, 'cases.create', 'tickets.create'):
|
||||||
|
raise HTTPException(403, detail='Du mangler rettighed til at oprette sager')
|
||||||
|
if row.get('responsible_user_id') and not execute_query_single(
|
||||||
|
'SELECT user_id FROM users WHERE user_id=%s AND is_active=TRUE', (row['responsible_user_id'],)
|
||||||
|
):
|
||||||
|
raise HTTPException(400, detail='Den ansvarlige bruger er ikke aktiv')
|
||||||
|
if row.get('template_id'):
|
||||||
|
if not _has(user, 'templates.run'):
|
||||||
|
raise HTTPException(403, detail='Du mangler rettighed til at anvende skabeloner')
|
||||||
|
if not execute_query_single('''SELECT id FROM task_templates WHERE id=%s
|
||||||
|
AND is_active=TRUE AND deleted_at IS NULL AND category=%s
|
||||||
|
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=%s))''',
|
||||||
|
(row['template_id'], row['event_type'], row['customer_id'])):
|
||||||
|
raise HTTPException(400, detail='Skabelonen er ikke tilgængelig for kunden og forløbstypen')
|
||||||
|
from app.contacts.backend.lifecycle_service import find_parent_candidates
|
||||||
|
candidates = find_parent_candidates(row['contact_id'], row['customer_id'], row['event_type'])
|
||||||
|
hidden = hidden_case_ids(user)
|
||||||
|
candidates = [c for c in candidates if c['id'] not in hidden]
|
||||||
|
# A generated case must never become its own parent during later edits.
|
||||||
|
candidates = [c for c in candidates if c['id'] != row.get('case_id')]
|
||||||
|
if row.get('parent_case_id') and row['parent_case_id'] not in [c['id'] for c in candidates]:
|
||||||
|
# Parent can have been closed after creation: retaining it is valid; changing it is not.
|
||||||
|
if not row.get('case_id'):
|
||||||
|
raise HTTPException(400, detail='Hovedsagen skal være en åben sag for samme kontakt, kunde og type')
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(row):
|
||||||
|
from app.contacts.backend.lifecycle_service import scheduled_at
|
||||||
|
result = dict(row)
|
||||||
|
result['scheduled_at'] = scheduled_at(row['event_date'], row['lead_days']).isoformat()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _find_event(contact_id, event_id, lock=False):
|
||||||
|
row = execute_query_single('SELECT * FROM contact_lifecycle_events WHERE contact_id=%s AND id=%s' +
|
||||||
|
(' FOR UPDATE' if lock else ''), (contact_id, event_id))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, detail='Forløbet findes ikke')
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/contacts/{contact_id}/lifecycle')
|
||||||
|
def list_lifecycle(contact_id: int, current_user: dict = Depends(read_access)):
|
||||||
|
_ready()
|
||||||
|
_contact(contact_id)
|
||||||
|
companies = execute_query('''SELECT c.id,c.name FROM customers c JOIN contact_companies cc
|
||||||
|
ON cc.customer_id=c.id WHERE cc.contact_id=%s ORDER BY c.name''', (contact_id,)) or []
|
||||||
|
templates = []
|
||||||
|
if _has(current_user, 'templates.view', 'templates.run'):
|
||||||
|
templates = execute_query('''SELECT id,name,category,customer_id,template_type FROM task_templates
|
||||||
|
WHERE deleted_at IS NULL AND is_active=TRUE AND category IN ('onboarding','offboarding')
|
||||||
|
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=ANY(%s)))
|
||||||
|
ORDER BY name''', ([c['id'] for c in companies],)) or []
|
||||||
|
hidden = hidden_case_ids(current_user)
|
||||||
|
items = []
|
||||||
|
for row in execute_query(
|
||||||
|
'SELECT * FROM contact_lifecycle_events WHERE contact_id=%s ORDER BY event_date DESC,id DESC',
|
||||||
|
(contact_id,)) or []:
|
||||||
|
item = _serialize(row)
|
||||||
|
for key in ('case_id', 'parent_case_id'):
|
||||||
|
if item.get(key) in hidden:
|
||||||
|
item[key] = None
|
||||||
|
items.append(item)
|
||||||
|
return {'items': items, 'companies': companies, 'templates': templates,
|
||||||
|
'can_edit': _has(current_user, 'cases.edit', 'tickets.edit'),
|
||||||
|
'users': execute_query('''SELECT user_id AS id,COALESCE(NULLIF(full_name,''),username) AS name
|
||||||
|
FROM users WHERE is_active=TRUE ORDER BY name''') or []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/contacts/{contact_id}/lifecycle/preview')
|
||||||
|
def preview_lifecycle(contact_id: int, customer_id: int = Query(gt=0),
|
||||||
|
event_type: EventType = Query(), event_date: date = Query(),
|
||||||
|
lead_days: int = Query(default=14, ge=0, le=3650),
|
||||||
|
current_user: dict = Depends(read_access)):
|
||||||
|
_ready()
|
||||||
|
_contact(contact_id)
|
||||||
|
_membership(contact_id, customer_id)
|
||||||
|
from app.contacts.backend.lifecycle_service import find_parent_candidates, scheduled_at
|
||||||
|
candidates = find_parent_candidates(contact_id, customer_id, event_type)
|
||||||
|
hidden = hidden_case_ids(current_user)
|
||||||
|
candidates = [c for c in candidates if c['id'] not in hidden]
|
||||||
|
return {'scheduled_at': scheduled_at(event_date, lead_days).isoformat(), 'candidates': candidates,
|
||||||
|
'parent_case_id': candidates[0]['id'] if len(candidates) == 1 else None,
|
||||||
|
'needs_parent': len(candidates) > 1}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/contacts/{contact_id}/lifecycle', status_code=201)
|
||||||
|
def create_lifecycle(contact_id: int, payload: LifecycleCreate, current_user: dict = Depends(edit_access)):
|
||||||
|
_ready()
|
||||||
|
_contact(contact_id)
|
||||||
|
row = dict(payload.model_dump(), contact_id=contact_id)
|
||||||
|
candidates = _validate(row, current_user)
|
||||||
|
status = 'draft' if not row['auto_create'] else (
|
||||||
|
'needs_parent' if len(candidates) > 1 and not row['parent_case_id'] else 'scheduled')
|
||||||
|
columns = list(row)
|
||||||
|
result = execute_query_single(f'''INSERT INTO contact_lifecycle_events
|
||||||
|
({','.join(columns)},status,created_by) VALUES ({','.join(['%s'] * len(columns))},%s,%s) RETURNING *''',
|
||||||
|
tuple(row.values()) + (status, current_user['id']))
|
||||||
|
return _serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch('/contacts/{contact_id}/lifecycle/{event_id}')
|
||||||
|
def update_lifecycle(contact_id: int, event_id: int, payload: LifecycleUpdate,
|
||||||
|
current_user: dict = Depends(edit_access)):
|
||||||
|
_ready()
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
for required in ('customer_id', 'event_date', 'lead_days', 'auto_create'):
|
||||||
|
if required in updates and updates[required] is None:
|
||||||
|
raise HTTPException(422, detail=f'{required} må ikke være tom')
|
||||||
|
with atomic_transaction():
|
||||||
|
previous = _find_event(contact_id, event_id, lock=True)
|
||||||
|
if previous.get('case_id'):
|
||||||
|
require_case(current_user, previous['case_id'])
|
||||||
|
if previous['status'] == 'cancelled':
|
||||||
|
raise HTTPException(409, detail='Et annulleret forløb kan ikke ændres; opret et nyt')
|
||||||
|
row = dict(previous, **updates)
|
||||||
|
if row.get('case_id') and any(row.get(k) != previous.get(k) for k in
|
||||||
|
('customer_id', 'template_id', 'parent_case_id', 'responsible_user_id')):
|
||||||
|
raise HTTPException(409, detail='Kunde, skabelon, hovedsag og ansvarlig ændres på den oprettede sag')
|
||||||
|
status = previous['status']
|
||||||
|
last_error = previous.get('last_error')
|
||||||
|
if row.get('case_id'):
|
||||||
|
if row['event_date'] != previous['event_date']:
|
||||||
|
from app.contacts.backend.lifecycle_service import update_created_case_date
|
||||||
|
result = update_created_case_date(row, previous['event_date'], current_user['id'])
|
||||||
|
status, last_error = result['status'], result.get('last_error')
|
||||||
|
else:
|
||||||
|
candidates = _validate(row, current_user)
|
||||||
|
status = 'draft' if not row['auto_create'] else (
|
||||||
|
'needs_parent' if len(candidates) > 1 and not row.get('parent_case_id') else 'scheduled')
|
||||||
|
last_error = None
|
||||||
|
if row['auto_create']:
|
||||||
|
# The last user authorizing automation is the actor whose rights
|
||||||
|
# are checked by the worker, including when a disabled user is replaced.
|
||||||
|
updates['created_by'] = current_user['id']
|
||||||
|
updates.update(status=status, last_error=last_error)
|
||||||
|
columns = list(updates)
|
||||||
|
result = execute_query_single(f'''UPDATE contact_lifecycle_events SET
|
||||||
|
{','.join(c + '=%s' for c in columns)},updated_at=NOW() WHERE id=%s RETURNING *''',
|
||||||
|
tuple(updates.values()) + (event_id,))
|
||||||
|
return _serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/contacts/{contact_id}/lifecycle/{event_id}/cancel')
|
||||||
|
def cancel_lifecycle(contact_id: int, event_id: int, current_user: dict = Depends(edit_access)):
|
||||||
|
_ready()
|
||||||
|
with atomic_transaction():
|
||||||
|
_find_event(contact_id, event_id, lock=True)
|
||||||
|
row = execute_query_single('''UPDATE contact_lifecycle_events SET status='cancelled',auto_create=FALSE,
|
||||||
|
last_error=NULL,updated_at=NOW() WHERE id=%s RETURNING *''', (event_id,))
|
||||||
|
return _serialize(row)
|
||||||
199
app/contacts/backend/lifecycle_service.py
Normal file
199
app/contacts/backend/lifecycle_service.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""Transactional contact lifecycle scheduling using Hub's existing case contracts."""
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.core.database import atomic_transaction, execute_query, execute_query_single
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
TZ = ZoneInfo("Europe/Copenhagen")
|
||||||
|
LABELS = {"onboarding": "Onboarding", "offboarding": "Offboarding"}
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_at(event_date, lead_days=14):
|
||||||
|
if isinstance(event_date, str):
|
||||||
|
event_date = date.fromisoformat(event_date)
|
||||||
|
return datetime.combine(event_date - timedelta(days=lead_days), time(8), TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline(event_date):
|
||||||
|
"""Existing case timestamps store UTC without timezone information."""
|
||||||
|
if isinstance(event_date, str):
|
||||||
|
event_date = date.fromisoformat(event_date)
|
||||||
|
return datetime.combine(event_date, time(17), TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def closed_statuses():
|
||||||
|
values = {"lukket", "løst", "afsluttet", "closed", "resolved", "done", "annulleret", "cancelled", "canceled"}
|
||||||
|
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
|
||||||
|
if row and row.get("value"):
|
||||||
|
try:
|
||||||
|
options = row["value"] if isinstance(row["value"], list) else json.loads(row["value"])
|
||||||
|
values.update(str(item["value"]).strip().lower() for item in options
|
||||||
|
if isinstance(item, dict) and item.get("is_closed") and item.get("value"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Ignoring invalid case_statuses configuration")
|
||||||
|
return sorted(values)
|
||||||
|
|
||||||
|
|
||||||
|
def find_parent_candidates(contact_id, customer_id, event_type):
|
||||||
|
"""Only explicit type/tag classification and direct person linkage qualify."""
|
||||||
|
return execute_query("""SELECT s.id,s.titel,s.status,s.customer_id
|
||||||
|
FROM sag_sager s
|
||||||
|
WHERE s.customer_id=%s AND s.deleted_at IS NULL
|
||||||
|
AND LOWER(TRIM(s.status)) <> ALL(%s)
|
||||||
|
AND EXISTS(SELECT 1 FROM sag_kontakter k WHERE k.sag_id=s.id
|
||||||
|
AND k.contact_id=%s AND k.deleted_at IS NULL)
|
||||||
|
AND (LOWER(s.type)=%s OR LOWER(s.template_key)=%s
|
||||||
|
OR EXISTS(SELECT 1 FROM sag_tags t WHERE t.sag_id=s.id
|
||||||
|
AND t.deleted_at IS NULL AND LOWER(TRIM(t.tag_navn))=%s))
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM sag_relationer r WHERE r."målsag_id"=s.id
|
||||||
|
AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL)
|
||||||
|
ORDER BY s.id DESC""",
|
||||||
|
(customer_id, closed_statuses(), contact_id, event_type, event_type, event_type)) or []
|
||||||
|
|
||||||
|
|
||||||
|
def update_created_case_date(lifecycle, previous_event_date, actor_id):
|
||||||
|
"""Called inside the API transaction; never changes the lifecycle row itself."""
|
||||||
|
if not lifecycle.get("case_id") or lifecycle["event_date"] == previous_event_date:
|
||||||
|
return {"status": lifecycle.get("status", "created"), "last_error": lifecycle.get("last_error")}
|
||||||
|
from app.core.case_visibility import require_case
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
actor = {"id": actor_id, "permissions": AuthService.get_user_permissions(actor_id)}
|
||||||
|
require_case(actor, lifecycle["case_id"])
|
||||||
|
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE",
|
||||||
|
(lifecycle["case_id"],))
|
||||||
|
if not case or case.get("deleted_at") or str(case["status"]).strip().lower() in closed_statuses():
|
||||||
|
return {"status": "manual_review", "last_error": "Datoen er ændret. Sagen er afsluttet eller fjernet; kontrollér deadline manuelt."}
|
||||||
|
execute_query("UPDATE sag_sager SET deadline=%s,updated_at=NOW() WHERE id=%s",
|
||||||
|
(_deadline(lifecycle["event_date"]), case["id"]))
|
||||||
|
_comment(case["id"], f"{LABELS[lifecycle['event_type']]}dato ændret fra {previous_event_date} til {lifecycle['event_date']}. Deadline opdateret.")
|
||||||
|
return {"status": "created", "last_error": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _comment(case_id, message):
|
||||||
|
execute_query("""INSERT INTO sag_kommentarer
|
||||||
|
(sag_id,forfatter,indhold,er_intern,er_system_besked)
|
||||||
|
VALUES(%s,'BMC Hub',%s,TRUE,TRUE)""", (case_id, html.escape(message)))
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(event):
|
||||||
|
from app.core.auth_service import AuthService
|
||||||
|
user = execute_query_single("SELECT user_id,is_active,is_superadmin,full_name FROM users WHERE user_id=%s",
|
||||||
|
(event["created_by"],))
|
||||||
|
if not user or not user["is_active"]:
|
||||||
|
raise HTTPException(403, "Forløbets opretter er ikke længere aktiv. Gem forløbet med en aktiv bruger.")
|
||||||
|
user["id"] = user["user_id"]
|
||||||
|
user["permissions"] = AuthService.get_user_permissions(user["id"])
|
||||||
|
if not user["is_superadmin"]:
|
||||||
|
permissions = set(user["permissions"])
|
||||||
|
if not permissions.intersection({"cases.create", "tickets.create"}):
|
||||||
|
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette sager.")
|
||||||
|
if event.get("template_id") and "templates.run" not in permissions:
|
||||||
|
raise HTTPException(403, "Forløbets opretter mangler rettighed til at bruge skabelonen.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_case(event):
|
||||||
|
from app.core.case_visibility import require_case
|
||||||
|
actor = _actor(event)
|
||||||
|
if not execute_query_single("SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s",
|
||||||
|
(event["contact_id"], event["customer_id"])):
|
||||||
|
raise HTTPException(409, "Kontakten er ikke længere tilknyttet kunden. Vælg kunde igen.")
|
||||||
|
candidates = find_parent_candidates(event["contact_id"], event["customer_id"], event["event_type"])
|
||||||
|
parent_id = event.get("parent_case_id")
|
||||||
|
if parent_id and parent_id not in {row["id"] for row in candidates}:
|
||||||
|
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
|
||||||
|
updated_at=NOW() WHERE id=%s""",
|
||||||
|
("Den valgte hovedsag kan ikke længere bruges. Vælg hovedsag igen.", event["id"]))
|
||||||
|
return False
|
||||||
|
if not parent_id and len(candidates) > 1:
|
||||||
|
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
|
||||||
|
updated_at=NOW() WHERE id=%s""",
|
||||||
|
("Flere åbne sager matcher. Vælg hovedsag på kontaktkortet.", event["id"]))
|
||||||
|
return False
|
||||||
|
parent_id = parent_id or (candidates[0]["id"] if candidates else None)
|
||||||
|
if parent_id:
|
||||||
|
require_case(actor, parent_id)
|
||||||
|
if not actor["is_superadmin"] and not set(actor["permissions"]).intersection({"cases.edit", "tickets.edit"}):
|
||||||
|
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette undersager.")
|
||||||
|
# Lock and recheck before adding a relation; a concurrently closed parent cannot be used.
|
||||||
|
parent = execute_query_single("SELECT status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE", (parent_id,))
|
||||||
|
if not parent or parent.get("deleted_at") or str(parent["status"]).strip().lower() in closed_statuses():
|
||||||
|
raise HTTPException(409, "Hovedsagen er afsluttet. Vælg hovedsag igen.")
|
||||||
|
contact = execute_query_single("SELECT first_name,last_name FROM contacts WHERE id=%s", (event["contact_id"],))
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(409, "Kontakten findes ikke længere.")
|
||||||
|
name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
|
||||||
|
label = LABELS[event["event_type"]]
|
||||||
|
responsible = event.get("responsible_user_id")
|
||||||
|
if responsible and not execute_query_single("SELECT user_id FROM users WHERE user_id=%s AND is_active", (responsible,)):
|
||||||
|
raise HTTPException(409, "Den ansvarlige bruger er ikke længere aktiv. Vælg ansvarlig igen.")
|
||||||
|
case = execute_query_single("""INSERT INTO sag_sager
|
||||||
|
(titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,created_by_user_id,deadline)
|
||||||
|
VALUES(%s,%s,'ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id""",
|
||||||
|
(f"{label}: {name}"[:255], f"{label} for {name}. Dato: {event['event_date']}.\nOprettet automatisk fra kontaktkortet.",
|
||||||
|
event["customer_id"], responsible, actor["id"], _deadline(event["event_date"])))
|
||||||
|
case_id = case["id"]
|
||||||
|
execute_query("INSERT INTO sag_kontakter(sag_id,contact_id,role,is_primary) VALUES(%s,%s,'Kontakt',TRUE)",
|
||||||
|
(case_id, event["contact_id"]))
|
||||||
|
execute_query("INSERT INTO sag_tags(sag_id,tag_navn) VALUES(%s,%s)", (case_id, event["event_type"]))
|
||||||
|
if parent_id:
|
||||||
|
execute_query('INSERT INTO sag_relationer(kilde_sag_id,"målsag_id",relationstype) VALUES(%s,%s,\'undersag\')',
|
||||||
|
(parent_id, case_id))
|
||||||
|
if event.get("template_id"):
|
||||||
|
if not execute_query_single("""SELECT id FROM task_templates WHERE id=%s
|
||||||
|
AND category=%s AND is_active AND deleted_at IS NULL""",
|
||||||
|
(event["template_id"], event["event_type"])):
|
||||||
|
raise HTTPException(409, "Skabelonen er ændret eller deaktiveret. Vælg skabelon igen.")
|
||||||
|
from app.modules.task_templates.backend.models import TemplateRunRequest
|
||||||
|
from app.modules.task_templates.backend.router import run_template_for_case
|
||||||
|
await run_template_for_case(case_id, TemplateRunRequest(template_id=event["template_id"], start_date=event["event_date"]), actor)
|
||||||
|
execute_query("""UPDATE contact_lifecycle_events SET case_id=%s,parent_case_id=%s,
|
||||||
|
status='created',last_error=NULL,updated_at=NOW() WHERE id=%s""",
|
||||||
|
(case_id, parent_id, event["id"]))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def process_due_lifecycles(now=None):
|
||||||
|
"""Row locks and a single transaction encompass case, relation and template writes."""
|
||||||
|
ready = execute_query_single("SELECT to_regclass('contact_lifecycle_events') IS NOT NULL AS ready")
|
||||||
|
if not ready or not ready["ready"]:
|
||||||
|
return 0
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
if now.tzinfo is None:
|
||||||
|
raise ValueError("now must include timezone")
|
||||||
|
rows = execute_query("""SELECT id FROM contact_lifecycle_events
|
||||||
|
WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error')
|
||||||
|
AND ((event_date-lead_days)+TIME '08:00') AT TIME ZONE 'Europe/Copenhagen' <= %s
|
||||||
|
ORDER BY event_date,id LIMIT 100""", (now,)) or []
|
||||||
|
created = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
with atomic_transaction():
|
||||||
|
event = execute_query_single("SELECT * FROM contact_lifecycle_events WHERE id=%s FOR UPDATE SKIP LOCKED", (row["id"],))
|
||||||
|
if not event or not event["auto_create"] or event.get("case_id") or event["status"] not in {"scheduled", "error"}:
|
||||||
|
continue
|
||||||
|
if scheduled_at(event["event_date"], event["lead_days"]) > now:
|
||||||
|
continue
|
||||||
|
did_create = await _create_case(event)
|
||||||
|
created += int(did_create)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Contact lifecycle %s failed", row["id"])
|
||||||
|
message = str(exc.detail) if isinstance(exc, HTTPException) else "Sagen kunne ikke oprettes. Systemet prøver igen; kontakt administrator hvis fejlen fortsætter."
|
||||||
|
execute_query("""UPDATE contact_lifecycle_events SET status='error',last_error=%s,updated_at=NOW()
|
||||||
|
WHERE id=%s AND case_id IS NULL AND status IN ('scheduled','error')""", (message, row["id"]))
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def check_contact_lifecycles():
|
||||||
|
"""Isolate lifecycle failures from other scheduled jobs."""
|
||||||
|
try:
|
||||||
|
return await process_due_lifecycles()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Contact lifecycle scheduler failed")
|
||||||
|
return 0
|
||||||
@ -34,6 +34,8 @@ from email.utils import parseaddr
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
from app.contacts.backend.lifecycle import router as lifecycle_router
|
||||||
|
router.include_router(lifecycle_router)
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(BaseModel):
|
class ContactCreate(BaseModel):
|
||||||
@ -687,6 +689,9 @@ def _contact_merge_counts(contact_id: int) -> list[dict]:
|
|||||||
for label, table, column in CONTACT_MERGE_RELATIONS:
|
for label, table, column in CONTACT_MERGE_RELATIONS:
|
||||||
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
|
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
|
||||||
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
|
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
|
||||||
|
if (execute_query_single("SELECT to_regclass('contact_lifecycle_events') AS relation") or {}).get('relation'):
|
||||||
|
row = execute_query_single("SELECT COUNT(*)::int AS count FROM contact_lifecycle_events WHERE contact_id=%s", (contact_id,)) or {}
|
||||||
|
counts.append({"key": "contact_lifecycle_events", "label": "Onboarding/offboarding", "count": int(row.get('count') or 0)})
|
||||||
conversation_row = execute_query_single(
|
conversation_row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT COUNT(DISTINCT conversation.id)::int AS count
|
SELECT COUNT(DISTINCT conversation.id)::int AS count
|
||||||
@ -817,6 +822,11 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest):
|
|||||||
moved[table] = int(cursor.fetchone()["count"] or 0)
|
moved[table] = int(cursor.fetchone()["count"] or 0)
|
||||||
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
|
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
|
||||||
|
|
||||||
|
cursor.execute("SELECT to_regclass('contact_lifecycle_events') AS relation")
|
||||||
|
if cursor.fetchone()['relation']:
|
||||||
|
cursor.execute("UPDATE contact_lifecycle_events SET contact_id=%s,updated_at=NOW() WHERE contact_id=%s", (contact_id, source_id))
|
||||||
|
moved['contact_lifecycle_events'] = cursor.rowcount
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
|
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
|
||||||
|
|||||||
@ -314,6 +314,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="info-card" id="contactLifecycle" aria-labelledby="contactLifecycleHeading">
|
||||||
|
<h5 class="fw-bold mb-2" id="contactLifecycleHeading">Onboarding og offboarding</h5>
|
||||||
|
<p class="text-muted small mb-3">Planlæg start og fratrædelse, og få oprettet en sag i god tid.</p>
|
||||||
|
<div id="contactLifecycleFeedback" class="d-none" role="status" aria-live="polite"></div>
|
||||||
|
<div class="row g-3" id="contactLifecycleCards"><div class="col-12 text-muted">Henter forløb…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h5 class="fw-bold mb-3">System Info</h5>
|
<h5 class="fw-bold mb-3">System Info</h5>
|
||||||
@ -934,6 +942,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script src="/static/js/contact-lifecycle.js?v=20261102-1"></script>
|
||||||
<!-- Alert Notes Components -->
|
<!-- Alert Notes Components -->
|
||||||
<script src="/static/alert_notes/alert_check.js"></script>
|
<script src="/static/alert_notes/alert_check.js"></script>
|
||||||
{% include "alert_notes/frontend/alert_box.html" %}
|
{% include "alert_notes/frontend/alert_box.html" %}
|
||||||
|
|||||||
36
docs/CONTACT_LIFECYCLE.md
Normal file
36
docs/CONTACT_LIFECYCLE.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Onboarding og offboarding på kontaktkortet
|
||||||
|
|
||||||
|
Kontaktkortets oversigt viser separate forløb. En dato kan gemmes uden automatik.
|
||||||
|
Automatik bruger kalenderdage og kl. 08 i Europe/Copenhagen, kontrolleres ved
|
||||||
|
API-start og hvert femte minut og indhenter forfaldne forløb efter nedetid.
|
||||||
|
Sagens deadline sættes til kl. 17 på datoen, gemt som UTC efter sagsmodulets konvention.
|
||||||
|
|
||||||
|
Åbne hovedsager matches på kunde, direkte kontaktrelation og eksplicit type/tag.
|
||||||
|
En kandidat giver en undersag; flere kræver valg på kontaktkortet. En oprettet
|
||||||
|
sags reference bevares ved datoændring. Afsluttede sager kræver manuel behandling.
|
||||||
|
Annullering stopper automatikken og sletter ingen sager.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Anvend kun `migrations/1076_contact_lifecycle.sql` i det ønskede miljø og genstart
|
||||||
|
API'et. Migrationen tilføjer en tabel og indeks uden at ændre eksisterende data.
|
||||||
|
Uden migration svarer de nye endpoints med 503; eksisterende kontakt-API bevares.
|
||||||
|
Ingen forløb eller sager oprettes af migrationen.
|
||||||
|
|
||||||
|
API: GET/POST `/api/v1/contacts/{id}/lifecycle`, GET `/preview`,
|
||||||
|
PATCH `/{event_id}` og POST `/{event_id}/cancel` under samme base.
|
||||||
|
Læsning kræver cases.view/tickets.view; redigering cases.edit/tickets.edit.
|
||||||
|
Automatik kræver desuden sagsoprettelse og skabeloner templates.run.
|
||||||
|
Automatikkens seneste godkender skal fortsat være aktiv og have rettighederne.
|
||||||
|
|
||||||
|
## Kontrol og drift
|
||||||
|
|
||||||
|
Kør `pytest tests/test_contact_lifecycle_api.py tests/test_contact_lifecycle_worker.py tests/test_contacts_router_simple.py`
|
||||||
|
med projektets venv og `node tests/js/contact-lifecycle.test.cjs`.
|
||||||
|
Kontrollér i browseren: gem dato uden automatik, planlæg automatisk forløb,
|
||||||
|
vælg mellem flere hovedsager, åbn oprettet sag, ændr dato og annuller.
|
||||||
|
Kortet viser fejl og behov for manuel behandling. Tekniske fejl logges af
|
||||||
|
jobbet `check_contact_lifecycles`; forløb i fejl genforsøges ved næste kørsel.
|
||||||
|
|
||||||
|
Ved tilbagerulning deaktiveres jobbet/kodeændringen; behold tabellen for at
|
||||||
|
bevare historik og referencer. Sammenlægning af kontakter flytter forløbene med.
|
||||||
13
main.py
13
main.py
@ -203,6 +203,19 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
logger.info("✅ Reminder job scheduled (every 5 minutes)")
|
logger.info("✅ Reminder job scheduled (every 5 minutes)")
|
||||||
|
|
||||||
|
# Contact lifecycle automation is independent of reminder notification settings.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.contacts.backend.lifecycle_service import check_contact_lifecycles
|
||||||
|
backup_scheduler.scheduler.add_job(
|
||||||
|
func=check_contact_lifecycles,
|
||||||
|
trigger=IntervalTrigger(minutes=5),
|
||||||
|
id='check_contact_lifecycles',
|
||||||
|
name='Contact Onboarding / Offboarding',
|
||||||
|
next_run_time=datetime.now(timezone.utc),
|
||||||
|
max_instances=1,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Register subscription invoice processing job
|
# Register subscription invoice processing job
|
||||||
from app.jobs.process_subscriptions import process_subscriptions
|
from app.jobs.process_subscriptions import process_subscriptions
|
||||||
|
|
||||||
|
|||||||
23
migrations/1076_contact_lifecycle.sql
Normal file
23
migrations/1076_contact_lifecycle.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- Additive contact onboarding/offboarding planning. Existing contacts and cases are unchanged.
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_lifecycle_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
contact_id INTEGER NOT NULL REFERENCES contacts(id),
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||||
|
event_type TEXT NOT NULL CHECK (event_type IN ('onboarding', 'offboarding')),
|
||||||
|
event_date DATE NOT NULL,
|
||||||
|
lead_days INTEGER NOT NULL DEFAULT 14 CHECK (lead_days BETWEEN 0 AND 3650),
|
||||||
|
auto_create BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
template_id INTEGER REFERENCES task_templates(id),
|
||||||
|
responsible_user_id INTEGER REFERENCES users(user_id),
|
||||||
|
parent_case_id INTEGER REFERENCES sag_sager(id),
|
||||||
|
case_id INTEGER UNIQUE REFERENCES sag_sager(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN
|
||||||
|
('draft','scheduled','needs_parent','created','manual_review','cancelled','error')),
|
||||||
|
last_error TEXT,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS contact_lifecycle_contact_idx ON contact_lifecycle_events(contact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS contact_lifecycle_due_idx ON contact_lifecycle_events(event_date)
|
||||||
|
WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error');
|
||||||
157
static/js/contact-lifecycle.js
Normal file
157
static/js/contact-lifecycle.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
const labels = {onboarding: 'Onboarding', offboarding: 'Offboarding'};
|
||||||
|
const statuses = {draft: 'Dato gemt · automatik slået fra', scheduled: 'Planlagt', needs_parent: 'Vælg hovedsag', created: 'Sag oprettet', manual_review: 'Kræver manuel behandling', cancelled: 'Annulleret', error: 'Oprettelse fejlede'};
|
||||||
|
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||||||
|
const date = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium'}).format(new Date(value.length === 10 ? value + 'T12:00:00Z' : value)) : 'Ikke planlagt';
|
||||||
|
const schedule = value => value ? new Intl.DateTimeFormat('da-DK', {timeZone:'Europe/Copenhagen', dateStyle:'medium', timeStyle:'short'}).format(new Date(value)) : '';
|
||||||
|
function templateOptions(templates, customerId, type) {
|
||||||
|
return templates.filter(item => (['global','internal'].includes(item.template_type) || !item.customer_id || Number(item.customer_id) === Number(customerId)) && (!item.category || item.category === type));
|
||||||
|
}
|
||||||
|
function editPayload(payload, created) {
|
||||||
|
if (!created) return payload;
|
||||||
|
return {event_date:payload.event_date,lead_days:payload.lead_days,auto_create:payload.auto_create};
|
||||||
|
}
|
||||||
|
root.ContactLifecycle = {escape, templateOptions, editPayload};
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const cards = document.getElementById('contactLifecycleCards');
|
||||||
|
if (!cards) return;
|
||||||
|
const contactId = Number(location.pathname.split('/').filter(Boolean).pop());
|
||||||
|
const base = `/api/v1/contacts/${contactId}/lifecycle`;
|
||||||
|
let data = {items:[],companies:[],users:[],templates:[]};
|
||||||
|
let current = null;
|
||||||
|
let previewVersion = 0;
|
||||||
|
const feedback = document.getElementById('contactLifecycleFeedback');
|
||||||
|
async function request(url, method = 'GET', body) {
|
||||||
|
const headers = {'Content-Type':'application/json'};
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const response = await fetch(url, {method, headers, credentials:'same-origin', ...(body === undefined ? {} : {body:JSON.stringify(body)})});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kontrollér oplysningerne, og prøv igen.');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function notify(message, danger = false) {
|
||||||
|
feedback.className = `alert ${danger ? 'alert-danger' : 'alert-success'} py-2`;
|
||||||
|
feedback.textContent = message;
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
data = await request(base);
|
||||||
|
cards.innerHTML = Object.entries(labels).map(([type,label]) => {
|
||||||
|
const items = data.items.filter(item => item.event_type === type);
|
||||||
|
const live = items.filter(item => item.status !== 'cancelled');
|
||||||
|
return `<div class="col-md-6"><div class="border rounded-3 p-3 h-100"><div class="d-flex align-items-center justify-content-between gap-2 mb-3"><h6 class="fw-bold mb-0">${label}</h6><button type="button" class="btn btn-sm btn-outline-primary" data-lifecycle-new="${type}"><i class="bi bi-plus-lg me-1"></i>Nyt forløb</button></div>${live.length ? live.map(item => `<div class="${live.length > 1 ? 'border-top pt-2 mt-2' : ''}"><div class="fw-semibold">${escape(date(item.event_date))}</div><div class="small text-muted">${escape((data.companies.find(company => Number(company.id) === Number(item.customer_id)) || {}).name || '')}</div><div class="small mt-1 ${['error','needs_parent','manual_review'].includes(item.status) ? 'text-warning-emphasis' : 'text-muted'}">${escape(statuses[item.status] || item.status)}</div>${item.auto_create && item.scheduled_at && !item.case_id ? `<div class="small text-muted">Oprettelse: ${escape(schedule(item.scheduled_at))}</div>` : ''}${item.last_error ? `<div class="small text-danger mt-1">${escape(item.last_error)}</div>` : ''}<div class="d-flex flex-wrap gap-2 mt-2">${item.case_id ? `<a class="btn btn-sm btn-outline-primary" href="/sag/${Number(item.case_id)}">Åbn sag #${Number(item.case_id)}</a>` : ''}<button type="button" class="btn btn-sm btn-outline-secondary" data-lifecycle-edit="${Number(item.id)}">${item.status === 'needs_parent' ? 'Vælg hovedsag' : 'Rediger'}</button><button type="button" class="btn btn-sm btn-link text-muted" data-lifecycle-cancel="${Number(item.id)}">Annuller forløb</button></div></div>`).join('') : '<div class="small text-muted">Ingen dato planlagt</div>'}${items.some(item => item.status === 'cancelled') ? `<div class="small text-muted mt-3">${items.filter(item => item.status === 'cancelled').length} annulleret · eksisterende sager er bevaret</div>` : ''}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
if (data.can_edit === false) cards.querySelectorAll('[data-lifecycle-new],[data-lifecycle-edit],[data-lifecycle-cancel]').forEach(button => button.remove());
|
||||||
|
} catch (error) {
|
||||||
|
cards.innerHTML = '<div class="col-12"><button type="button" class="btn btn-outline-secondary btn-sm" data-lifecycle-reload>Prøv igen</button></div>';
|
||||||
|
notify(`Forløbene kunne ikke hentes: ${error.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cards.addEventListener('click', async event => {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
if (!button) return;
|
||||||
|
if (button.hasAttribute('data-lifecycle-reload')) return load();
|
||||||
|
if (button.dataset.lifecycleNew) return open(button.dataset.lifecycleNew);
|
||||||
|
if (button.dataset.lifecycleEdit) {
|
||||||
|
const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleEdit));
|
||||||
|
if (item) open(item.event_type, item);
|
||||||
|
}
|
||||||
|
if (button.dataset.lifecycleCancel) {
|
||||||
|
const item = data.items.find(item => Number(item.id) === Number(button.dataset.lifecycleCancel));
|
||||||
|
if (!item || !confirm(`Annuller ${labels[item.event_type].toLowerCase()} den ${date(item.event_date)}? Eksisterende sager bevares.`)) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try { await request(`${base}/${item.id}/cancel`, 'POST'); await load(); notify('Forløbet er annulleret. Eksisterende sager er bevaret.'); }
|
||||||
|
catch (error) { notify(error.message, true); button.disabled = false; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function open(type, item = null) {
|
||||||
|
if (data.can_edit === false) return;
|
||||||
|
current = item;
|
||||||
|
previewVersion++;
|
||||||
|
document.getElementById('contactLifecycleModal')?.remove();
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.id = 'contactLifecycleModal';
|
||||||
|
element.className = 'modal fade';
|
||||||
|
element.tabIndex = -1;
|
||||||
|
element.setAttribute('aria-labelledby','contactLifecycleModalTitle');
|
||||||
|
element.innerHTML = `<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"><form class="modal-content" id="contactLifecycleForm"><div class="modal-header"><div><h5 class="modal-title" id="contactLifecycleModalTitle">${item ? 'Rediger' : 'Planlæg'} ${labels[type].toLowerCase()}</h5><div class="small text-muted">Dato og arbejdsopgaver for denne kontakt</div></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body"><div class="alert alert-danger d-none" id="lifecycleError" role="alert"></div><div class="row g-3"><div class="col-md-6"><label class="form-label" for="lifecycleDate">${type === 'onboarding' ? 'Startdato' : 'Fratrædelsesdato'}</label><input class="form-control" type="date" id="lifecycleDate" name="event_date" required></div><div class="col-md-6"><label class="form-label" for="lifecycleCompany">Kunde</label><select class="form-select" id="lifecycleCompany" name="customer_id" required><option value="">Vælg kunde</option>${data.companies.map(company => `<option value="${Number(company.id)}">${escape(company.name)}</option>`).join('')}</select></div><div class="col-12"><div class="form-check form-switch"><input class="form-check-input" id="lifecycleAuto" name="auto_create" type="checkbox"><label class="form-check-label fw-semibold" for="lifecycleAuto">Opret automatisk en sag før datoen</label></div></div><div class="col-12" id="lifecycleAutoFields"><div class="row g-3"><div class="col-md-6"><label class="form-label" for="lifecycleLead">Kalenderdage før</label><input class="form-control" id="lifecycleLead" name="lead_days" type="number" min="0" max="3650" required><div class="d-flex gap-2 mt-2">${[7,14,30].map(days => `<button type="button" class="btn btn-sm btn-outline-secondary" data-lead="${days}">${days} dage</button>`).join('')}</div></div><div class="col-md-6"><label class="form-label" for="lifecycleResponsible">Ansvarlig</label><select class="form-select" id="lifecycleResponsible" name="responsible_user_id"><option value="">Ikke tildelt</option>${data.users.map(user => `<option value="${Number(user.id)}">${escape(user.name)}</option>`).join('')}</select></div><div class="col-12"><label class="form-label" for="lifecycleTemplate">Opgaveskabelon</label><select class="form-select" id="lifecycleTemplate" name="template_id"></select><div class="form-text">Uden skabelon bruges kontaktens navn, kunde og dato.</div></div><div class="col-12"><label class="form-label" for="lifecycleParent">Hovedsag</label><select class="form-select" id="lifecycleParent" name="parent_case_id"><option value="">Finder relevante åbne sager…</option></select><div class="form-text" id="lifecycleParentHelp"></div></div></div></div><div class="col-12"><div class="bg-light border rounded-3 p-3 small" id="lifecyclePreview" role="status" aria-live="polite">Vælg dato og kunde.</div></div></div></div><div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button type="submit" class="btn btn-primary" id="lifecycleSave">Gem forløb</button></div></form></div>`;
|
||||||
|
document.body.append(element);
|
||||||
|
const form = element.querySelector('form');
|
||||||
|
const field = name => form.elements.namedItem(name);
|
||||||
|
field('event_date').value = item?.event_date || '';
|
||||||
|
field('customer_id').value = item?.customer_id || (data.companies.length === 1 ? data.companies[0].id : '');
|
||||||
|
field('lead_days').value = item?.lead_days ?? 14;
|
||||||
|
field('auto_create').checked = Boolean(item?.auto_create);
|
||||||
|
field('responsible_user_id').value = item?.responsible_user_id || '';
|
||||||
|
if (item?.case_id) ['customer_id','template_id','parent_case_id','responsible_user_id'].forEach(name => { field(name).disabled = true; });
|
||||||
|
let templateId = item?.template_id || '';
|
||||||
|
function templates() {
|
||||||
|
const available = templateOptions(data.templates, field('customer_id').value, type);
|
||||||
|
field('template_id').innerHTML = '<option value="">Ingen skabelon</option>' + available.map(template => `<option value="${Number(template.id)}">${escape(template.name)}</option>`).join('');
|
||||||
|
field('template_id').value = available.some(template => Number(template.id) === Number(templateId)) ? templateId : '';
|
||||||
|
}
|
||||||
|
async function preview() {
|
||||||
|
const version = ++previewVersion;
|
||||||
|
const container = element.querySelector('#lifecyclePreview');
|
||||||
|
element.querySelector('#lifecycleAutoFields').classList.toggle('d-none', !field('auto_create').checked);
|
||||||
|
const customer = field('customer_id').value;
|
||||||
|
const eventDate = field('event_date').value;
|
||||||
|
const days = Number(field('lead_days').value);
|
||||||
|
if (!customer || !eventDate || !Number.isInteger(days) || days < 0) { container.textContent = 'Vælg dato og kunde.'; return; }
|
||||||
|
if (current?.case_id) {
|
||||||
|
field('parent_case_id').innerHTML = `<option value="">${current.parent_case_id ? `Hovedsag #${Number(current.parent_case_id)}` : 'Selvstændig sag'}</option>`;
|
||||||
|
element.querySelector('#lifecycleParentHelp').textContent = 'Sagens tilknytninger bevares.';
|
||||||
|
container.textContent = `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedParent = field('parent_case_id').value || current?.parent_case_id;
|
||||||
|
container.textContent = 'Henter forslag…';
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({customer_id:customer,event_type:type,event_date:eventDate,lead_days:String(days)});
|
||||||
|
const result = await request(`${base}/preview?${query}`);
|
||||||
|
if (version !== previewVersion || !element.isConnected) return;
|
||||||
|
const candidates = result.candidates || [];
|
||||||
|
field('parent_case_id').innerHTML = `<option value="">${candidates.length ? 'Automatisk valg' : 'Selvstændig sag'}</option>` + candidates.map(candidate => `<option value="${Number(candidate.id)}">#${Number(candidate.id)} · ${escape(candidate.titel || candidate.title || '')}</option>`).join('');
|
||||||
|
field('parent_case_id').value = candidates.some(candidate => Number(candidate.id) === Number(selectedParent)) ? selectedParent : (result.parent_case_id || '');
|
||||||
|
element.querySelector('#lifecycleParentHelp').textContent = candidates.length > 1 ? 'Flere relevante sager: vælg hovedsag. Uden valg afventer automatikken.' : candidates.length === 1 ? 'Der oprettes en undersag til denne åbne sag.' : 'Ingen relevant åben sag. Der oprettes en selvstændig sag.';
|
||||||
|
container.textContent = current?.case_id ? `Sag #${current.case_id} bevares. Deadline ændres til ${date(eventDate)}. Afsluttede sager kræver manuel behandling.` : field('auto_create').checked ? `Sagen oprettes ${schedule(result.scheduled_at)} med deadline ${date(eventDate)}.${candidates.length > 1 && !field('parent_case_id').value ? ' Afventer valg af hovedsag.' : ''}` : `Datoen ${date(eventDate)} gemmes. Ingen automatisk sagsoprettelse.`;
|
||||||
|
} catch (error) { if (version === previewVersion) container.textContent = `Forslag kunne ikke hentes: ${error.message}`; }
|
||||||
|
}
|
||||||
|
templates();
|
||||||
|
form.addEventListener('change', event => {
|
||||||
|
if (event.target.name === 'customer_id') templates();
|
||||||
|
if (event.target.name === 'template_id') templateId = event.target.value;
|
||||||
|
if (!['template_id','responsible_user_id'].includes(event.target.name)) preview();
|
||||||
|
});
|
||||||
|
form.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-lead]');
|
||||||
|
if (button) { field('lead_days').value = button.dataset.lead; preview(); }
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!form.reportValidity()) return;
|
||||||
|
const button = element.querySelector('#lifecycleSave');
|
||||||
|
const errorBox = element.querySelector('#lifecycleError');
|
||||||
|
button.disabled = true;
|
||||||
|
errorBox.classList.add('d-none');
|
||||||
|
const payload = {customer_id:Number(field('customer_id').value),event_date:field('event_date').value,lead_days:Number(field('lead_days').value),auto_create:field('auto_create').checked,template_id:Number(field('template_id').value)||null,responsible_user_id:Number(field('responsible_user_id').value)||null,parent_case_id:Number(field('parent_case_id').value)||null};
|
||||||
|
if (!current) payload.event_type = type;
|
||||||
|
try {
|
||||||
|
const saved = await request(current ? `${base}/${current.id}` : base, current ? 'PATCH' : 'POST', editPayload(payload, Boolean(current?.case_id)));
|
||||||
|
modal.hide();
|
||||||
|
await load();
|
||||||
|
notify(saved.status === 'needs_parent' ? 'Forløbet er gemt. Vælg en hovedsag for at aktivere sagsoprettelsen.' : 'Forløbet er gemt.');
|
||||||
|
} catch (error) { errorBox.textContent = error.message; errorBox.classList.remove('d-none'); button.disabled = false; }
|
||||||
|
});
|
||||||
|
const modal = new bootstrap.Modal(element);
|
||||||
|
element.addEventListener('shown.bs.modal', () => field('event_date').focus(), {once:true});
|
||||||
|
element.addEventListener('hidden.bs.modal', () => { previewVersion++; modal.dispose(); element.remove(); }, {once:true});
|
||||||
|
modal.show();
|
||||||
|
preview();
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
})(typeof window === 'undefined' ? globalThis : window);
|
||||||
18
tests/js/contact-lifecycle.test.cjs
Normal file
18
tests/js/contact-lifecycle.test.cjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
global.window = {};
|
||||||
|
require('../../static/js/contact-lifecycle.js');
|
||||||
|
const {escape, templateOptions, editPayload} = window.ContactLifecycle;
|
||||||
|
assert.equal(escape('<img src=x onerror="bad">'), '<img src=x onerror="bad">');
|
||||||
|
const templates = [
|
||||||
|
{id:1,category:'onboarding',customer_id:null},
|
||||||
|
{id:2,category:'onboarding',customer_id:12},
|
||||||
|
{id:3,category:'offboarding',customer_id:12},
|
||||||
|
{id:4,category:'onboarding',customer_id:13},
|
||||||
|
];
|
||||||
|
assert.deepEqual(templateOptions(templates, '12', 'onboarding').map(item => item.id), [1,2]);
|
||||||
|
assert.deepEqual(templateOptions(templates, '12', 'offboarding').map(item => item.id), [3]);
|
||||||
|
assert.deepEqual(templateOptions(templates, '', 'onboarding').map(item => item.id), [1]);
|
||||||
|
const update = {customer_id:12,event_date:'2026-11-20',lead_days:14,auto_create:true,parent_case_id:99,template_id:1,responsible_user_id:2};
|
||||||
|
assert.deepEqual(editPayload(update,true), {event_date:'2026-11-20',lead_days:14,auto_create:true});
|
||||||
|
assert.equal(editPayload(update,false), update);
|
||||||
|
console.log('Contact lifecycle escaping and customer/type template scoping passed');
|
||||||
@ -15,7 +15,8 @@ def test_mail_inbox_supports_unread_deep_link():
|
|||||||
|
|
||||||
assert "get('filter') === 'unread'" in source
|
assert "get('filter') === 'unread'" in source
|
||||||
assert "url += '&unread=true'" in source
|
assert "url += '&unread=true'" in source
|
||||||
assert "state.filter === 'unread' ? 500 : 150" in source
|
# The inbox now uses a single, count-aware page size for every filter.
|
||||||
|
assert "pageSize: 100" in source
|
||||||
|
|
||||||
|
|
||||||
def test_procurement_page_applies_requested_state_filter():
|
def test_procurement_page_applies_requested_state_filter():
|
||||||
|
|||||||
123
tests/test_contact_lifecycle_api.py
Normal file
123
tests/test_contact_lifecycle_api.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
from contextlib import nullcontext
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.contacts.backend import lifecycle as api
|
||||||
|
from app.contacts.backend import lifecycle_service as service
|
||||||
|
|
||||||
|
|
||||||
|
USER = {'id': 5, 'is_superadmin': True}
|
||||||
|
|
||||||
|
|
||||||
|
def event(**overrides):
|
||||||
|
return dict(id=9, contact_id=1, customer_id=2, event_type='onboarding',
|
||||||
|
event_date=date(2026, 11, 17), lead_days=14, auto_create=False,
|
||||||
|
template_id=None, responsible_user_id=None, parent_case_id=None,
|
||||||
|
case_id=None, status='draft', last_error=None, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_api(monkeypatch):
|
||||||
|
monkeypatch.setattr(api, '_ready', lambda: None)
|
||||||
|
monkeypatch.setattr(api, '_contact', lambda _: None)
|
||||||
|
monkeypatch.setattr(api, '_membership', lambda *args: None)
|
||||||
|
monkeypatch.setattr(api, 'atomic_transaction', nullcontext)
|
||||||
|
monkeypatch.setattr(api, 'hidden_case_ids', lambda _: set())
|
||||||
|
monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_automation_is_disabled_and_lead_is_fourteen():
|
||||||
|
request = api.LifecycleCreate(customer_id=2, event_type='onboarding', event_date='2026-11-17')
|
||||||
|
assert request.auto_create is False
|
||||||
|
assert request.lead_days == 14
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
api.LifecycleCreate(customer_id=2, event_type='offboarding', event_date='2026-11-17', lead_days=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_migration_is_explicit_503(monkeypatch):
|
||||||
|
monkeypatch.setattr(api, 'execute_query_single', lambda *args: {'relation': None})
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
api._ready()
|
||||||
|
assert error.value.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_must_belong_to_contact(monkeypatch):
|
||||||
|
monkeypatch.setattr(api, 'execute_query_single', lambda *args: None)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
api._membership(1, 90)
|
||||||
|
assert error.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabling_automation_requires_case_creation(setup_api):
|
||||||
|
row = event()
|
||||||
|
row['auto_create'] = True
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
api._validate(row, {'id': 5, 'permissions': ['cases.edit']})
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_requires_run_permission(setup_api):
|
||||||
|
row = event()
|
||||||
|
row['template_id'] = 4
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
api._validate(row, {'id': 5, 'permissions': ['cases.edit']})
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_parent_saves_waiting_for_choice(setup_api, monkeypatch):
|
||||||
|
monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [{'id': 10}, {'id': 11}])
|
||||||
|
writes = []
|
||||||
|
def query(sql, params):
|
||||||
|
writes.append((sql, params))
|
||||||
|
row = event()
|
||||||
|
row.update(auto_create=True, status='needs_parent')
|
||||||
|
return row
|
||||||
|
monkeypatch.setattr(api, 'execute_query_single', query)
|
||||||
|
result = api.create_lifecycle(1, api.LifecycleCreate(
|
||||||
|
customer_id=2, event_type='onboarding', event_date='2026-11-17', auto_create=True), USER)
|
||||||
|
assert result['status'] == 'needs_parent'
|
||||||
|
assert writes[0][1][-2] == 'needs_parent'
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_excludes_confidential_candidates(setup_api, monkeypatch):
|
||||||
|
monkeypatch.setattr(service, 'find_parent_candidates', lambda *args: [{'id': 10}, {'id': 11}])
|
||||||
|
monkeypatch.setattr(api, 'hidden_case_ids', lambda _: {11})
|
||||||
|
result = api.preview_lifecycle(1, 2, 'onboarding', date(2026, 11, 17), 14, USER)
|
||||||
|
assert result['candidates'] == [{'id': 10}]
|
||||||
|
assert result['parent_case_id'] == 10
|
||||||
|
assert result['needs_parent'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_change_updates_same_case_without_insert(setup_api, monkeypatch):
|
||||||
|
row = event()
|
||||||
|
row.update(case_id=45, status='created')
|
||||||
|
monkeypatch.setattr(api, '_find_event', lambda *args, **kwargs: row)
|
||||||
|
monkeypatch.setattr(api, 'require_case', lambda *args: None)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(service, 'update_created_case_date', lambda *args:
|
||||||
|
calls.append(args) or {'status': 'created', 'last_error': None})
|
||||||
|
writes = []
|
||||||
|
def query(sql, params):
|
||||||
|
writes.append(sql)
|
||||||
|
return dict(row, event_date=date(2026, 11, 20))
|
||||||
|
monkeypatch.setattr(api, 'execute_query_single', query)
|
||||||
|
result = api.update_lifecycle(1, 9, api.LifecycleUpdate(event_date='2026-11-20'), USER)
|
||||||
|
assert result['case_id'] == 45
|
||||||
|
assert calls[0][1] == date(2026, 11, 17)
|
||||||
|
assert all('INSERT' not in sql for sql in writes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_preserves_case_and_disables_automation(setup_api, monkeypatch):
|
||||||
|
monkeypatch.setattr(api, '_find_event', lambda *args, **kwargs: event())
|
||||||
|
writes = []
|
||||||
|
def query(sql, params):
|
||||||
|
writes.append(sql)
|
||||||
|
return event()
|
||||||
|
monkeypatch.setattr(api, 'execute_query_single', query)
|
||||||
|
api.cancel_lifecycle(1, 9, USER)
|
||||||
|
assert 'auto_create=FALSE' in writes[0]
|
||||||
|
assert 'DELETE' not in writes[0]
|
||||||
|
assert 'case_id=' not in writes[0]
|
||||||
109
tests/test_contact_lifecycle_worker.py
Normal file
109
tests/test_contact_lifecycle_worker.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.contacts.backend import lifecycle_service as service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("event,lead,expected", [
|
||||||
|
(date(2026, 3, 30), 1, "2026-03-29T08:00:00+02:00"),
|
||||||
|
(date(2026, 10, 26), 1, "2026-10-25T08:00:00+01:00"),
|
||||||
|
(date(2026, 1, 3), 7, "2025-12-27T08:00:00+01:00"),
|
||||||
|
])
|
||||||
|
def test_calendar_days_preserve_eight_am_across_dst(event, lead, expected):
|
||||||
|
assert service.scheduled_at(event, lead).isoformat() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_query_requires_person_customer_type_and_open_main_case(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(service, "closed_statuses", lambda: ["closed"])
|
||||||
|
monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params)) or [])
|
||||||
|
assert service.find_parent_candidates(11, 22, "offboarding") == []
|
||||||
|
sql, params = calls[0]
|
||||||
|
assert params == (22, ["closed"], 11, "offboarding", "offboarding", "offboarding")
|
||||||
|
assert "s.customer_id=%s" in sql and "k.contact_id=%s" in sql
|
||||||
|
assert "LOWER(s.type)=%s" in sql and "LOWER(TRIM(t.tag_navn))=%s" in sql
|
||||||
|
assert "LIKE" not in sql and "'undersag'" in sql and "<> ALL" in sql
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction():
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_job_rechecks_locked_row_and_catches_up_once(monkeypatch):
|
||||||
|
event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14)
|
||||||
|
created = []
|
||||||
|
|
||||||
|
def single(sql, params=None):
|
||||||
|
return {"ready": True} if "to_regclass" in sql else dict(event)
|
||||||
|
|
||||||
|
async def create(row):
|
||||||
|
created.append(row["id"])
|
||||||
|
event["case_id"] = 70
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "execute_query_single", single)
|
||||||
|
monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}])
|
||||||
|
monkeypatch.setattr(service, "atomic_transaction", transaction)
|
||||||
|
monkeypatch.setattr(service, "_create_case", create)
|
||||||
|
now = datetime(2026, 11, 2, tzinfo=timezone.utc)
|
||||||
|
assert asyncio.run(service.process_due_lifecycles(now)) == 1
|
||||||
|
assert asyncio.run(service.process_due_lifecycles(now)) == 0
|
||||||
|
assert created == [9]
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_event_rescheduled_after_candidate_scan_is_not_created(monkeypatch):
|
||||||
|
event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2027, 1, 1), lead_days=0)
|
||||||
|
monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event)
|
||||||
|
monkeypatch.setattr(service, "execute_query", lambda *args: [{"id": 9}])
|
||||||
|
monkeypatch.setattr(service, "atomic_transaction", transaction)
|
||||||
|
assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ambiguous_parent_blocks_before_any_case_is_created(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(service, "_actor", lambda event: {"id": 1, "is_superadmin": True})
|
||||||
|
monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 1})
|
||||||
|
monkeypatch.setattr(service, "find_parent_candidates", lambda *args: [{"id": 10}, {"id": 20}])
|
||||||
|
monkeypatch.setattr(service, "execute_query", lambda sql, params: calls.append((sql, params)))
|
||||||
|
event = dict(id=9, contact_id=11, customer_id=22, event_type="onboarding", parent_case_id=None)
|
||||||
|
assert asyncio.run(service._create_case(event)) is False
|
||||||
|
assert len(calls) == 1 and "status='needs_parent'" in calls[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_change_of_closed_case_requires_manual_review(monkeypatch):
|
||||||
|
from app.core import auth_service, case_visibility
|
||||||
|
monkeypatch.setattr(auth_service.AuthService, "get_user_permissions", lambda uid: ["cases.edit"])
|
||||||
|
monkeypatch.setattr(case_visibility, "require_case", lambda *args: None)
|
||||||
|
monkeypatch.setattr(service, "closed_statuses", lambda: ["lukket"])
|
||||||
|
monkeypatch.setattr(service, "execute_query_single", lambda *args: {"id": 70, "status": "Lukket", "deleted_at": None})
|
||||||
|
writes = []
|
||||||
|
monkeypatch.setattr(service, "execute_query", lambda *args: writes.append(args))
|
||||||
|
result = service.update_created_case_date(dict(case_id=70, event_date=date(2026, 12, 1), event_type="offboarding"), date(2026, 11, 1), 1)
|
||||||
|
assert result["status"] == "manual_review" and not writes
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_failure_escapes_case_transaction_for_rollback(monkeypatch):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
event = dict(id=9, auto_create=True, case_id=None, status="scheduled", event_date=date(2026, 1, 1), lead_days=14)
|
||||||
|
rolled_back = []
|
||||||
|
@contextmanager
|
||||||
|
def rollback_transaction():
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except Exception:
|
||||||
|
rolled_back.append(True)
|
||||||
|
raise
|
||||||
|
async def fail(event):
|
||||||
|
raise HTTPException(409, "Skabelon utilgængelig")
|
||||||
|
writes = []
|
||||||
|
monkeypatch.setattr(service, "execute_query_single", lambda sql, *args: {"ready": True} if "to_regclass" in sql else event)
|
||||||
|
monkeypatch.setattr(service, "execute_query", lambda sql, params: [{"id": 9}] if sql.lstrip().startswith("SELECT") else writes.append((sql, params)))
|
||||||
|
monkeypatch.setattr(service, "atomic_transaction", rollback_transaction)
|
||||||
|
monkeypatch.setattr(service, "_create_case", fail)
|
||||||
|
assert asyncio.run(service.process_due_lifecycles(datetime(2026, 11, 2, tzinfo=timezone.utc))) == 0
|
||||||
|
assert rolled_back == [True]
|
||||||
|
assert writes[0][1] == ("Skabelon utilgængelig", 9)
|
||||||
@ -271,6 +271,7 @@ def test_detection_service_analyze_sweeps_historical_months(monkeypatch):
|
|||||||
"_analyze_single_month",
|
"_analyze_single_month",
|
||||||
fake_analyze_single_month,
|
fake_analyze_single_month,
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(DetectionService, "_ignore_existing_issues", lambda self: None)
|
||||||
|
|
||||||
service = DetectionService()
|
service = DetectionService()
|
||||||
counts = service.analyze()
|
counts = service.analyze()
|
||||||
@ -385,7 +386,7 @@ def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def fake_execute_query(query, params=None):
|
def fake_execute_query(query, params=None):
|
||||||
if "FROM month_window mw" in query:
|
if "SELECT generate_series(" in query:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"month_start": date(2026, 6, 1),
|
"month_start": date(2026, 6, 1),
|
||||||
@ -418,7 +419,7 @@ def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
|
|||||||
"is_reference_month": False,
|
"is_reference_month": False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if "WITH ranked_invoices AS" in query:
|
if "FROM invoice_error_finder_economic_invoices inv" in query:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"month_start": date(2026, 6, 1),
|
"month_start": date(2026, 6, 1),
|
||||||
@ -527,7 +528,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch)
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def fake_execute_query(query, params=None):
|
def fake_execute_query(query, params=None):
|
||||||
if "FROM month_window mw" in query:
|
if "SELECT generate_series(" in query:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"month_start": date(2026, 7, 1),
|
"month_start": date(2026, 7, 1),
|
||||||
@ -540,7 +541,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch)
|
|||||||
"is_reference_month": True,
|
"is_reference_month": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if "WITH ranked_invoices AS" in query:
|
if "FROM invoice_error_finder_economic_invoices inv" in query:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"month_start": date(2026, 7, 1),
|
"month_start": date(2026, 7, 1),
|
||||||
@ -557,7 +558,7 @@ def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch)
|
|||||||
"line_number": 1,
|
"line_number": 1,
|
||||||
"product_number": "INET-1000",
|
"product_number": "INET-1000",
|
||||||
"product_name": None,
|
"product_name": None,
|
||||||
"description": "Fiberforbindelse",
|
"description": "1/1 Gbit Internet",
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"unit_price": 722.0,
|
"unit_price": 722.0,
|
||||||
"line_net_amount": 722.0,
|
"line_net_amount": 722.0,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
@ -7,6 +8,8 @@ from app.core.database import execute_query
|
|||||||
|
|
||||||
def test_module_deactivation():
|
def test_module_deactivation():
|
||||||
"""Ensure module deactivation preserves data integrity."""
|
"""Ensure module deactivation preserves data integrity."""
|
||||||
|
if os.getenv("BMC_HUB_INTEGRATION_DB") != "1":
|
||||||
|
pytest.skip("requires an explicitly configured, isolated integration database")
|
||||||
# Simulate module deactivation
|
# Simulate module deactivation
|
||||||
execute_query("UPDATE modules SET enabled = false WHERE name = 'sag';")
|
execute_query("UPDATE modules SET enabled = false WHERE name = 'sag';")
|
||||||
|
|
||||||
@ -26,4 +29,4 @@ def test_module_deactivation():
|
|||||||
|
|
||||||
assert soft_deleted_cases is not None, "Soft-deleted cases should be preserved."
|
assert soft_deleted_cases is not None, "Soft-deleted cases should be preserved."
|
||||||
assert soft_deleted_relations is not None, "Soft-deleted relations should be preserved."
|
assert soft_deleted_relations is not None, "Soft-deleted relations should be preserved."
|
||||||
assert soft_deleted_tags is not None, "Soft-deleted tags should be preserved."
|
assert soft_deleted_tags is not None, "Soft-deleted tags should be preserved."
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user