diff --git a/VERSION b/VERSION index 6ece8e7..e8f6dcd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.17 +2.8.18 diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 7b87ce6..d0e8a6c 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -168,6 +168,19 @@ class ContactSyncRequest(BaseModel): add_sync_ok_tag: Optional[bool] = False +class HouseOrganizationCreate(BaseModel): + name: str + location_id: Optional[int] = None + notes: Optional[str] = None + + +class HouseUserCreate(ContactCreate): + location_id: Optional[int] = None + external_customer_id: Optional[int] = None + external_company_name: Optional[str] = None + notes: Optional[str] = None + + @router.get("/customers") async def list_customers( limit: int = Query(default=50, ge=1, le=1000), @@ -175,7 +188,8 @@ async def list_customers( search: Optional[str] = Query(default=None), source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None is_active: Optional[bool] = Query(default=None), - vip: Optional[bool] = Query(default=None) + vip: Optional[bool] = Query(default=None), + include_house_companies: bool = Query(default=False) ): """ List customers with pagination and filtering @@ -208,6 +222,11 @@ async def list_customers( WHERE 1=1 """ params = [] + + if not include_house_companies: + # Existing customers remain visible. New firms registered only as house + # users are intentionally hidden from the ordinary customer picker. + query += " AND COALESCE(c.show_in_customer_list, TRUE) = TRUE" # Add search filter if search: @@ -300,6 +319,81 @@ async def list_customers( } +@router.get("/customers/duplicates") +async def find_customer_duplicates(): + """Read-only duplicate candidates grouped by normalized customer name.""" + rows = execute_query( + """ + WITH normalized_customers AS ( + SELECT + c.id, + c.name, + c.economic_customer_number, + c.cvr_number, + c.email, + c.is_active, + c.is_bmc_customer, + c.show_in_customer_list, + c.created_at, + LOWER(REGEXP_REPLACE(BTRIM(c.name), '[[:space:]]+', ' ', 'g')) AS duplicate_key + FROM customers c + WHERE COALESCE(c.is_active, TRUE) = TRUE + ), grouped AS ( + SELECT duplicate_key + FROM normalized_customers + GROUP BY duplicate_key + HAVING COUNT(*) > 1 + ) + SELECT + nc.duplicate_key, + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', nc.id, + 'name', nc.name, + 'economic_customer_number', nc.economic_customer_number, + 'cvr_number', nc.cvr_number, + 'email', nc.email, + 'is_bmc_customer', nc.is_bmc_customer, + 'show_in_customer_list', nc.show_in_customer_list, + 'created_at', nc.created_at, + 'relation_counts', JSONB_BUILD_OBJECT( + 'contacts', (SELECT COUNT(*) FROM contact_companies cc WHERE cc.customer_id = nc.id), + 'sager', (SELECT COUNT(*) FROM sag_sager ss WHERE ss.customer_id = nc.id AND ss.deleted_at IS NULL), + 'lokationer', (SELECT COUNT(*) FROM locations_locations ll WHERE ll.customer_id = nc.id), + 'hardware', (SELECT COUNT(*) FROM hardware_assets ha WHERE ha.current_owner_customer_id = nc.id), + 'abonnementer', (SELECT COUNT(*) FROM sag_subscriptions sub WHERE sub.customer_id = nc.id) + ) + ) + ORDER BY + (nc.economic_customer_number IS NOT NULL) DESC, + (nc.cvr_number IS NOT NULL) DESC, + nc.created_at ASC, + nc.id ASC + ) AS candidates, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT nc.economic_customer_number), NULL) AS economic_numbers + FROM normalized_customers nc + JOIN grouped g ON g.duplicate_key = nc.duplicate_key + GROUP BY nc.duplicate_key + ORDER BY nc.duplicate_key + """ + ) or [] + + groups = [] + for row in rows: + numbers = row.get("economic_numbers") or [] + groups.append({ + "key": row.get("duplicate_key"), + "candidates": row.get("candidates") or [], + "economic_numbers": numbers, + "economic_status": ( + "same_economic" if len(numbers) == 1 else + "conflicting_economic" if len(numbers) > 1 else + "missing_economic" + ), + }) + return {"groups": groups, "total_groups": len(groups)} + + @router.get("/customers/verify-linking") async def verify_customer_linking(): """ @@ -1342,21 +1436,163 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/customers/{customer_id}/house-users") +async def get_customer_house_users(customer_id: int): + """Return external firms and people using this customer's house/location.""" + customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + organizations = execute_query( + """ + SELECT cho.id, cho.location_id, cho.notes, cho.relationship_type, + ext.id AS external_customer_id, ext.name, ext.email, ext.phone, + loc.name AS location_name + FROM customer_house_organizations cho + JOIN customers ext ON ext.id = cho.external_customer_id + LEFT JOIN locations_locations loc ON loc.id = cho.location_id + WHERE cho.host_customer_id = %s AND cho.is_active = TRUE + ORDER BY ext.name, loc.name NULLS FIRST + """, + (customer_id,), + ) or [] + users = execute_query( + """ + SELECT clu.id, clu.location_id, clu.external_customer_id, clu.relation_type, + clu.role, clu.notes, c.id AS contact_id, c.first_name, c.last_name, + c.email, c.phone, c.mobile, ext.name AS external_company_name, + loc.name AS location_name + FROM contact_location_users clu + JOIN contacts c ON c.id = clu.contact_id + LEFT JOIN customers ext ON ext.id = clu.external_customer_id + LEFT JOIN locations_locations loc ON loc.id = clu.location_id + WHERE clu.host_customer_id = %s AND clu.is_active = TRUE AND c.is_active = TRUE + ORDER BY c.first_name, c.last_name + """, + (customer_id,), + ) or [] + return {"organizations": organizations, "users": users} + + +@router.post("/customers/{customer_id}/house-organizations") +async def create_customer_house_organization(customer_id: int, organization: HouseOrganizationCreate): + """Register a firm that uses a customer's house without making it a BMC customer.""" + host = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)) + if not host: + raise HTTPException(status_code=404, detail="Customer not found") + name = organization.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Firmanavn er påkrævet") + + external_customer_id = execute_insert( + """ + INSERT INTO customers (name, is_active, is_bmc_customer, show_in_customer_list) + VALUES (%s, TRUE, FALSE, FALSE) + RETURNING id + """, + (name,), + ) + execute_update( + """ + INSERT INTO customer_house_organizations + (host_customer_id, external_customer_id, location_id, notes) + VALUES (%s, %s, %s, %s) + ON CONFLICT (host_customer_id, external_customer_id, location_id) + DO UPDATE SET is_active = TRUE, notes = COALESCE(EXCLUDED.notes, customer_house_organizations.notes), updated_at = NOW() + """, + (customer_id, external_customer_id, organization.location_id, organization.notes), + ) + return execute_query_single("SELECT * FROM customers WHERE id = %s", (external_customer_id,)) + + +@router.post("/customers/{customer_id}/house-users") +async def create_customer_house_user(customer_id: int, user: HouseUserCreate): + """Create a person who uses this house, optionally under an external firm.""" + host = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)) + if not host: + raise HTTPException(status_code=404, detail="Customer not found") + + external_customer_id = user.external_customer_id + company_name = (user.external_company_name or "").strip() + if company_name and not external_customer_id: + external_customer_id = execute_insert( + """ + INSERT INTO customers (name, is_active, is_bmc_customer, show_in_customer_list) + VALUES (%s, TRUE, FALSE, FALSE) + RETURNING id + """, + (company_name,), + ) + if external_customer_id: + external = execute_query_single("SELECT id FROM customers WHERE id = %s", (external_customer_id,)) + if not external: + raise HTTPException(status_code=404, detail="Eksternt firma blev ikke fundet") + execute_update( + """ + INSERT INTO customer_house_organizations (host_customer_id, external_customer_id, location_id) + VALUES (%s, %s, %s) + ON CONFLICT (host_customer_id, external_customer_id, location_id) + DO UPDATE SET is_active = TRUE, updated_at = NOW() + """, + (customer_id, external_customer_id, user.location_id), + ) + + normalized_email = (user.email or "").strip().lower() or None + contact_id = None + if normalized_email: + existing = execute_query_single( + "SELECT id FROM contacts WHERE LOWER(COALESCE(email, '')) = %s ORDER BY id LIMIT 1", + (normalized_email,), + ) + contact_id = existing.get("id") if existing else None + if not contact_id: + contact_id = execute_insert( + """ + INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + (user.first_name.strip(), user.last_name.strip(), normalized_email, user.phone, user.mobile, user.title, user.department), + ) + if external_customer_id: + execute_update( + """ + INSERT INTO contact_companies (contact_id, customer_id, is_primary, role) + VALUES (%s, %s, FALSE, %s) + ON CONFLICT (contact_id, customer_id) DO NOTHING + """, + (contact_id, external_customer_id, user.role), + ) + execute_update( + """ + INSERT INTO contact_location_users + (contact_id, host_customer_id, location_id, external_customer_id, relation_type, role, notes) + VALUES (%s, %s, %s, %s, 'house_user', %s, %s) + """, + (contact_id, customer_id, user.location_id, external_customer_id, user.role, user.notes), + ) + return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,)) + + @router.get("/customers/{customer_id}/contacts") async def get_customer_contacts(customer_id: int): - """Get all contacts for a specific customer""" + """Get direct contacts plus people registered as users of this customer's house.""" rows = execute_query(""" - SELECT + SELECT DISTINCT ON (c.id) c.*, - cc.is_primary, - cc.role, - cc.notes + COALESCE(cc.is_primary, FALSE) AS is_primary, + COALESCE(cc.role, clu.role) AS role, + COALESCE(cc.notes, clu.notes) AS notes, + ext.name AS external_company_name, + CASE WHEN clu.id IS NULL THEN FALSE ELSE TRUE END AS is_house_user FROM contacts c - JOIN contact_companies cc ON c.id = cc.contact_id - WHERE cc.customer_id = %s AND c.is_active = TRUE - ORDER BY cc.is_primary DESC, c.first_name, c.last_name - """, (customer_id,)) - + LEFT JOIN contact_companies cc ON c.id = cc.contact_id AND cc.customer_id = %s + LEFT JOIN contact_location_users clu + ON clu.contact_id = c.id AND clu.host_customer_id = %s AND clu.is_active = TRUE + LEFT JOIN customers ext ON ext.id = clu.external_customer_id + WHERE c.is_active = TRUE AND (cc.customer_id IS NOT NULL OR clu.id IS NOT NULL) + ORDER BY c.id, cc.is_primary DESC, clu.id DESC + """, (customer_id, customer_id)) return rows or [] diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 617008f..268d49d 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -879,6 +879,11 @@ Kontakter + + {% if hardware.eset_uuid or hardware.hardware_specs or uisp_device or switch_ports %} + {% endif %} + {% if locations or ownership %} + {% endif %} + {% if rental_stats.total_bindings or rental_stats.order_count %} + {% endif %} + {% if rental_stats.total_bindings or rental_stats.order_count %} + {% endif %} + {% if attachments %} + {% endif %} + {% if hardware.notes %} + {% endif %}
@@ -386,6 +403,7 @@ {% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %} + {% if anydesk_url %}
Remote Access
@@ -418,6 +436,7 @@
+ {% endif %}
diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index e27ea8c..ff27b61 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -22,12 +22,14 @@ Phase 2.5 Endpoints (Bulk Operations & Advanced Queries): 13. GET /api/v1/locations/stats - Statistics about all locations """ -from fastapi import APIRouter, HTTPException, Query, Request -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form +from fastapi.responses import RedirectResponse, FileResponse from typing import Optional, List, Any from datetime import datetime, time, date import logging import json +from pathlib import Path +from uuid import uuid4 from pydantic import ValidationError from app.core.database import execute_query @@ -2429,6 +2431,102 @@ async def create_hours(location_id: int, data: OperatingHoursCreate): # 3. PATCH /api/v1/locations/{location_id}/hours/{day_id} - Update hours # ============================================================================ +_ACCESS_FILE_EXTENSIONS = {"pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx"} +_ACCESS_FILE_MAX_BYTES = 15 * 1024 * 1024 + + +@router.post("/locations/{location_id}/access-files") +async def upload_access_files( + location_id: int, + files: List[UploadFile] = File(...), + comment: Optional[str] = Form(None), +): + """Upload access instructions, photos, or key documentation for a location.""" + location = execute_query( + "SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL", (location_id,) + ) or [] + if not location: + raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet") + + upload_dir = Path("uploads") / "location_access" / str(location_id) + upload_dir.mkdir(parents=True, exist_ok=True) + saved = [] + for upload in files: + original_name = Path(upload.filename or "").name + extension = Path(original_name).suffix.lower().lstrip(".") + if not original_name or extension not in _ACCESS_FILE_EXTENSIONS: + raise HTTPException(status_code=400, detail="Filtypen er ikke tilladt") + content = await upload.read() + if not content or len(content) > _ACCESS_FILE_MAX_BYTES: + raise HTTPException(status_code=400, detail="Filen skal være mellem 1 byte og 15 MB") + stored_name = f"{uuid4().hex}.{extension}" + (upload_dir / stored_name).write_bytes(content) + row = execute_query( + """ + INSERT INTO locations_access_files (location_id, original_filename, stored_filename, content_type, file_size, comment) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, original_filename, file_size, comment, created_at + """, + (location_id, original_name, stored_name, upload.content_type, len(content), (comment or "").strip() or None), + ) + saved.append(row[0]) + return {"files": saved} + + +@router.get("/locations/{location_id}/access-files/{file_id}") +async def download_access_file(location_id: int, file_id: int): + rows = execute_query( + """SELECT original_filename, stored_filename, content_type FROM locations_access_files + WHERE id = %s AND location_id = %s AND deleted_at IS NULL""", + (file_id, location_id), + ) or [] + if not rows: + raise HTTPException(status_code=404, detail="Filen blev ikke fundet") + row = rows[0] + path = Path("uploads") / "location_access" / str(location_id) / row["stored_filename"] + if not path.is_file(): + raise HTTPException(status_code=404, detail="Filen findes ikke på serveren") + return FileResponse(path, media_type=row.get("content_type") or "application/octet-stream", filename=row["original_filename"]) + + +@router.post("/locations/{location_id}/files") +async def upload_location_files(location_id: int, files: List[UploadFile] = File(...), comment: Optional[str] = Form(None)): + location = execute_query("SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL", (location_id,)) or [] + if not location: + raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet") + upload_dir = Path("uploads") / "locations" / str(location_id) + upload_dir.mkdir(parents=True, exist_ok=True) + saved = [] + for upload in files: + original_name = Path(upload.filename or "").name + extension = Path(original_name).suffix.lower().lstrip(".") + if extension not in {"jpg", "jpeg", "png", "gif", "webp"}: + raise HTTPException(status_code=400, detail="Upload kun billeder (JPG, PNG, GIF eller WebP)") + content = await upload.read() + if not content or len(content) > _ACCESS_FILE_MAX_BYTES: + raise HTTPException(status_code=400, detail="Billedet skal være mellem 1 byte og 15 MB") + stored_name = f"{uuid4().hex}.{extension}" + (upload_dir / stored_name).write_bytes(content) + row = execute_query( + """INSERT INTO locations_files (location_id, original_filename, stored_filename, content_type, file_size, comment) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, original_filename, comment""", + (location_id, original_name, stored_name, upload.content_type, len(content), (comment or "").strip() or None), + ) + saved.append(row[0]) + return {"files": saved} + + +@router.get("/locations/{location_id}/files/{file_id}") +async def download_location_file(location_id: int, file_id: int): + rows = execute_query("""SELECT original_filename, stored_filename, content_type FROM locations_files + WHERE id = %s AND location_id = %s AND deleted_at IS NULL""", (file_id, location_id)) or [] + if not rows: + raise HTTPException(status_code=404, detail="Billedet blev ikke fundet") + row = rows[0] + path = Path("uploads") / "locations" / str(location_id) / row["stored_filename"] + if not path.is_file(): + raise HTTPException(status_code=404, detail="Billedet findes ikke på serveren") + return FileResponse(path, media_type=row.get("content_type") or "application/octet-stream", filename=row["original_filename"]) + @router.patch("/locations/{location_id}/hours/{day_id}", response_model=OperatingHours) async def update_hours(location_id: int, day_id: int, data: OperatingHoursUpdate): """ diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index b0a8aa5..5845804 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -213,6 +213,7 @@ def calculate_pagination(total: int, limit: int, skip: int) -> dict: def list_locations_view( location_type: Optional[str] = Query(None, description="Filter by type"), is_active: Optional[str] = Query(None, description="Filter by active status"), + q: Optional[str] = Query(None, max_length=120, description="Search name or city"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100) ): @@ -251,9 +252,20 @@ def list_locations_view( if is_active_bool is not None: where_clauses.append("is_active = %s") query_params.append(is_active_bool) + + search_term = (q or "").strip() + if search_term: + where_clauses.append("(name ILIKE %s OR COALESCE(address_city, '') ILIKE %s)") + query_params.extend([f"%{search_term}%", f"%{search_term}%"]) where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" + total_rows = execute_query( + f"SELECT COUNT(*) AS count FROM locations_locations WHERE {where_sql}", + tuple(query_params), + ) or [] + total = int(total_rows[0].get("count", 0)) if total_rows else 0 + query = f""" SELECT * FROM locations_locations WHERE {where_sql} @@ -301,10 +313,6 @@ def list_locations_view( location_tree = build_tree(locations if isinstance(locations, list) else []) - # Get total count (API returns full list, so count locally) - # In production, the API should return {data: [...], total: N} - total = len(locations) if isinstance(locations, list) else locations.get("total", 0) - # Calculate pagination info pagination = calculate_pagination(total, limit, skip) @@ -317,6 +325,7 @@ def list_locations_view( limit=limit, location_type=location_type, is_active=is_active_bool, # Use boolean value for template + q=search_term, page_number=pagination["page_number"], total_pages=pagination["total_pages"], has_prev=pagination["has_prev"], @@ -563,7 +572,13 @@ def detail_location_view(id: int = Path(..., gt=0)): # Query location details directly location = execute_query( - "SELECT * FROM locations_locations WHERE id = %s", + """ + SELECT l.*, p.name AS parent_location_name, c.name AS customer_name + FROM locations_locations l + LEFT JOIN locations_locations p ON p.id = l.parent_location_id + LEFT JOIN customers c ON c.id = l.customer_id + WHERE l.id = %s AND l.deleted_at IS NULL + """, (id,) ) @@ -596,6 +611,68 @@ def detail_location_view(id: int = Path(..., gt=0)): (id,) ) + tree_root_id = hierarchy[0]["id"] if hierarchy else id + relation_rows = execute_query( + """ + WITH RECURSIVE descendants AS ( + SELECT id, name, location_type, parent_location_id, is_active, + 0 AS depth, ARRAY[id] AS path + FROM locations_locations + WHERE id = %s AND deleted_at IS NULL + + UNION ALL + + SELECT child.id, child.name, child.location_type, child.parent_location_id, child.is_active, + descendants.depth + 1, descendants.path || child.id + FROM locations_locations child + JOIN descendants ON child.parent_location_id = descendants.id + WHERE child.deleted_at IS NULL + AND NOT child.id = ANY(descendants.path) + ) + SELECT id, name, location_type, parent_location_id, is_active, depth + FROM descendants + ORDER BY path + """, + (tree_root_id,), + ) or [] + + relation_nodes = { + row["id"]: {**row, "children": []} + for row in relation_rows + if row.get("id") is not None + } + relation_tree = relation_nodes.get(tree_root_id) + for node in relation_nodes.values(): + parent_id = node.get("parent_location_id") + if parent_id in relation_nodes and parent_id != node.get("id"): + relation_nodes[parent_id]["children"].append(node) + for node in relation_nodes.values(): + node["children"].sort(key=lambda child: (child.get("name") or "").lower()) + + def add_descendant_counts(node: dict) -> int: + descendant_count = sum(1 + add_descendant_counts(child) for child in node["children"]) + node["descendant_count"] = descendant_count + return descendant_count + + if relation_tree: + add_descendant_counts(relation_tree) + direct_child_nodes = relation_nodes.get(id, {}).get("children", []) + + tree_location_ids = list(relation_nodes) + same_customer_locations = [] + if location.get("customer_id"): + same_customer_locations = execute_query( + """ + SELECT id, name, location_type, parent_location_id, is_active + FROM locations_locations + WHERE customer_id = %s + AND deleted_at IS NULL + AND id <> ALL(%s) + ORDER BY name + """, + (location["customer_id"], tree_location_ids), + ) or [] + contacts = execute_query( """ SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone, @@ -627,6 +704,20 @@ def detail_location_view(id: int = Path(..., gt=0)): (id,) ) + access_files = execute_query( + """SELECT id, original_filename, file_size, comment, created_at + FROM locations_access_files WHERE location_id = %s AND deleted_at IS NULL + ORDER BY created_at DESC""", + (id,), + ) or [] + + location_files = execute_query( + """SELECT id, original_filename, comment, created_at + FROM locations_files WHERE location_id = %s AND deleted_at IS NULL + ORDER BY created_at DESC""", + (id,), + ) or [] + services = execute_query( """ SELECT id, location_id, service_name, is_available, created_at @@ -815,8 +906,14 @@ def detail_location_view(id: int = Path(..., gt=0)): location["hierarchy"] = hierarchy location["children"] = children + location["relation_tree"] = relation_tree + location["direct_child_nodes"] = direct_child_nodes + location["related_location_count"] = max(len(relation_rows) - 1, 0) + location["same_customer_locations"] = same_customer_locations location["contacts"] = contacts or [] location["operating_hours"] = operating_hours or [] + location["access_files"] = access_files + location["files"] = location_files location["services"] = services or [] location["capacity"] = capacity or [] location["hardware"] = hardware or [] diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html index 37b4789..87c3edc 100644 --- a/app/modules/locations/templates/detail.html +++ b/app/modules/locations/templates/detail.html @@ -210,6 +210,162 @@ display: none; } +.locations-detail-page .relation-overview { + background: linear-gradient(135deg, rgba(15, 76, 117, 0.08), rgba(255, 255, 255, 0.9)); +} + +.locations-detail-page .relation-path { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.locations-detail-page .relation-path a, +.locations-detail-page .relation-current { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.65rem; + border: 1px solid rgba(15, 76, 117, 0.16); + border-radius: 999px; + background: var(--bg-card, #fff); + font-size: 0.84rem; + font-weight: 650; + text-decoration: none; +} + +.locations-detail-page .relation-current { + color: #fff; + background: var(--loc-accent); + border-color: var(--loc-accent); +} + +.locations-detail-page .location-structure-focus { + border: 1px solid rgba(15, 76, 117, 0.16); + border-radius: 0.85rem; + background: var(--bg-card, #fff); + padding: 0.85rem 1rem; +} + +.locations-detail-page .location-structure-children { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 0.65rem; + margin-top: 0.75rem; +} + +.locations-detail-page .location-child-card { + display: block; + min-height: 5.3rem; + padding: 0.75rem 0.85rem; + border: 1px solid rgba(15, 76, 117, 0.14); + border-radius: 0.7rem; + background: linear-gradient(180deg, #fff, rgba(15, 76, 117, 0.035)); + color: var(--text-primary); + text-decoration: none; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; +} + +.locations-detail-page .location-child-card:hover { + transform: translateY(-2px); + border-color: rgba(15, 76, 117, 0.42); + box-shadow: 0 8px 18px rgba(15, 76, 117, 0.12); + color: var(--loc-accent); +} + +.locations-detail-page .location-child-card-name { font-weight: 750; } +.locations-detail-page .location-child-card-meta { font-size: 0.78rem; color: var(--text-secondary); margin-top: 0.3rem; } + +.locations-detail-page .location-full-tree, +.locations-detail-page .location-full-tree ul { + list-style: none; + margin: 0; + padding-left: 0; +} + +.locations-detail-page .location-full-tree > li { margin-top: 0.85rem; } + +.locations-detail-page .location-tree-canvas { + padding: 0.35rem 0.25rem 0.6rem; + border-radius: 0.8rem; + background: radial-gradient(circle at top right, rgba(21, 111, 166, 0.1), transparent 38%), #f8fafc; +} + +.locations-detail-page .location-full-tree ul { + margin: 0.35rem 0 0.2rem 1rem; + padding-left: 1.25rem; + border-left: 2px solid rgba(15, 76, 117, 0.18); +} + +.locations-detail-page .location-full-tree li { position: relative; margin: 0.45rem 0; } + +.locations-detail-page .location-full-tree ul > li::before { + content: ""; + position: absolute; + top: 1.35rem; + left: -1.25rem; + width: 1.1rem; + border-top: 2px solid rgba(15, 76, 117, 0.18); +} + +.locations-detail-page .location-tree-card { + display: inline-flex; + align-items: center; + gap: 0.7rem; + min-width: min(100%, 25rem); + padding: 0.62rem 0.8rem; + border: 1px solid rgba(15, 76, 117, 0.16); + border-radius: 0.75rem; + background: var(--bg-card, #fff); + color: var(--text-primary); + text-decoration: none; + box-shadow: 0 3px 8px rgba(15, 76, 117, 0.06); + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; +} + +.locations-detail-page .location-tree-card:hover { transform: translateX(3px); border-color: var(--loc-accent); background: rgba(15, 76, 117, 0.06); color: var(--loc-accent); box-shadow: 0 7px 16px rgba(15, 76, 117, 0.12); } +.locations-detail-page .location-tree-card.current { background: var(--loc-accent); border-color: var(--loc-accent); color: #fff; font-weight: 750; } +.locations-detail-page .tree-card-icon { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: 0.55rem; background: rgba(15, 76, 117, 0.1); color: var(--loc-accent); flex: 0 0 auto; } +.locations-detail-page .location-tree-card.current .tree-card-icon { background: rgba(255, 255, 255, 0.18); color: #fff; } +.locations-detail-page .tree-card-title { font-weight: 750; } +.locations-detail-page .location-tree-card .tree-card-meta { color: var(--text-secondary); font-size: 0.74rem; margin-left: auto; text-align: right; } +.locations-detail-page .location-tree-card.current .tree-card-meta { color: rgba(255, 255, 255, 0.82); } +.locations-detail-page .location-photo-thumb { width: 180px; border: 1px solid rgba(15,76,117,.14); border-radius: .7rem; background: #fff; overflow: hidden; padding: 0; text-align: left; } +.locations-detail-page .location-photo-thumb img { display: block; width: 100%; height: 120px; object-fit: cover; } +.locations-detail-page .location-photo-thumb:hover { border-color: var(--loc-accent); box-shadow: 0 6px 16px rgba(15,76,117,.13); } +.locations-detail-page .location-photo-caption { padding: .5rem .6rem; font-size: .78rem; } +#locationPhotoPreviewImage { max-height: min(70vh, 720px); max-width: 100%; object-fit: contain; } + +.locations-detail-page .relation-tree, +.locations-detail-page .relation-tree ul { + list-style: none; + margin: 0; + padding-left: 0; +} + +.locations-detail-page .relation-tree ul { + margin: 0.35rem 0 0.2rem 0.8rem; + padding-left: 1rem; + border-left: 1px solid rgba(15, 76, 117, 0.2); +} + +.locations-detail-page .relation-tree li { margin: 0.3rem 0; } + +.locations-detail-page .relation-tree-link, +.locations-detail-page .relation-tree-current { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.38rem 0.6rem; + border-radius: 0.55rem; + text-decoration: none; +} + +.locations-detail-page .relation-tree-link:hover { background: rgba(15, 76, 117, 0.08); } +.locations-detail-page .relation-tree-current { background: rgba(15, 76, 117, 0.12); color: var(--loc-accent); font-weight: 750; } +.locations-detail-page .relation-tree-type { color: var(--text-secondary); font-size: 0.76rem; font-weight: 500; } + @media (max-width: 767.98px) { .locations-detail-page { padding-left: 0.5rem; @@ -312,8 +468,8 @@
{{ location.contacts|length if location.contacts else 0 }}
-
Tjenester
-
{{ location.services|length if location.services else 0 }}
+
Underlokationer
+
{{ location.children|length if location.children else 0 }}
@@ -335,19 +491,7 @@ - - {% if location.location_type == 'rum' and location.has_cross_field %} @@ -379,6 +523,61 @@
+
+
+
Lokationens relationer
+
Viser kun direkte overordnet og direkte underlokationer.
+
+
+
+
Aktuel lokation
+
+ +
{{ location.name }}
{{ type_label }}
+
+
+
+
+
+
Overordnet lokation
+ {% if location.parent_location_id and location.parent_location_name %} + + + {{ location.parent_location_name }} + Åbn + + {% else %} + Denne lokation er øverst i strukturen. + {% endif %} +
+
+
+
+
Underlokationer
+ {% if location.children %} +
+ {% for child in location.children %} + +
{{ child.name }}
+
{{ child.location_type or 'Lokation' }}
+
+ {% endfor %} +
+ {% else %} + Ingen direkte underlokationer. + {% endif %} +
+
+
+
+
+
+
Billeder på lokation
Foto af adgang, rum, installationer eller skiltning.
+
+
Træk billeder hertileller klik for at vælge filer
+ {% if location.files %}
{% for file in location.files %}{% endfor %}
{% else %}Ingen billeder uploadet endnu.{% endif %} +
+
@@ -487,44 +686,6 @@
-
-
-
Hierarki (træ)
-
-
- {% if location.hierarchy or location.children %} - - {% else %} - Ingen relationer registreret - {% endif %} -
-
@@ -580,11 +741,12 @@
- +
-
-
Åbningstider
+
+
Adgangsforhold
+
{% if location.operating_hours %} @@ -613,7 +775,7 @@ {% endif %} - @@ -623,7 +785,12 @@
{% else %} -

Ingen åbningstider registreret

+

Ingen adgangsforhold registreret

+ {% endif %} + {% if location.access_files %} +
Filer til adgangsforhold
+ {% for file in location.access_files %}
{{ file.original_filename }}{% if file.comment %}
{{ file.comment }}
{% endif %}
{% endfor %} +
{% endif %}
@@ -704,6 +871,58 @@
+
+
+
+
Alle relaterede lokationer
+
Overordnede, søskende og underlokationer samlet ét sted. Klik på en lokation for at åbne den.
+
+ {{ location.related_location_count }} relaterede +
+
+ {% macro render_relation_node(node) %} +
  • + {% if node.id == location.id %} + {{ node.name }} Denne lokation + {% else %} + + {{ node.name }} + {% if node.location_type %}{{ node.location_type }}{% endif %} + + {% endif %} + {% if node.children %} +
      + {% for child in node.children %}{{ render_relation_node(child) }}{% endfor %} +
    + {% endif %} +
  • + {% endmacro %} + {% if location.relation_tree %} +
      {{ render_relation_node(location.relation_tree) }}
    + {% else %} + Ingen relationer registreret. + {% endif %} +
    +
    + {% if location.same_customer_locations %} +
    +
    +
    +
    Andre lokationer hos samme kunde
    +
    De er knyttet til samme kunde, men ligger ikke i dette lokationstræ.
    +
    + {{ location.same_customer_locations|length }} +
    +
    + {% for related in location.same_customer_locations %} + + {{ related.name }} + {{ related.location_type or 'Lokation' }} + + {% endfor %} +
    +
    + {% endif %}
    @@ -824,44 +1043,6 @@
    -
    -
    -
    Hierarki (træ)
    -
    -
    - {% if location.hierarchy or location.children %} - - {% else %} - Ingen relationer registreret - {% endif %} -
    -
    {% if location.location_type == 'rum' and location.has_cross_field %} @@ -897,8 +1078,9 @@
    -
    +
    Hardware på lokation
    +
    {% if location.hardware %} @@ -1173,6 +1355,46 @@
    + + + + + + + +
    + + Klik kun på navn eller handlinger for at åbne @@ -570,7 +576,7 @@
      {% if page_number > 1 %}
    • - + Forrige
    • @@ -587,7 +593,7 @@ {% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 1 and page_num <= page_number + 1) %}
    • - + {{ page_num }}
    • @@ -600,7 +606,7 @@ {% if page_number < total_pages %}
    • - + Næste
    • @@ -733,6 +739,16 @@ document.addEventListener('DOMContentLoaded', function() { }); } + function expandAll() { + rows.forEach(row => { + row.classList.remove('d-none'); + if (row.getAttribute('data-has-children') === 'true') { + updateToggleIcon(row, true); + } + }); + updateSummaryStats(); + } + function showAncestors(id) { let current = parentById.get(id); while (current) { @@ -772,6 +788,12 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + document.getElementById('expandAllBtn')?.addEventListener('click', expandAll); + document.getElementById('collapseAllBtn')?.addEventListener('click', function() { + collapseAll(); + updateSummaryStats(); + }); + document.querySelectorAll('.location-row.has-children').forEach(row => { row.addEventListener('click', function(e) { const target = e.target; diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index a5c2059..ece6640 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -499,6 +499,27 @@
    + + `; - }).join(''); + }).join('') + contactCreateActions(); resultsDiv.querySelectorAll('[data-contact-id]').forEach((row) => { row.addEventListener('click', () => { const contact = contacts.find((item) => Number(item.id) === Number(row.dataset.contactId)); @@ -1333,6 +1363,53 @@ } } + function openCaseContactCreateModal(kind) { + if (!selectedCustomer) return; + caseContactCreateKind = kind; + const currentName = document.getElementById('contactSearch').value.trim(); + const parts = currentName.split(/\s+/).filter(Boolean); + document.getElementById('caseContactCreateForm').reset(); + document.getElementById('caseContactFirstName').value = parts.shift() || ''; + document.getElementById('caseContactLastName').value = parts.join(' '); + document.getElementById('caseContactCreateError').classList.add('d-none'); + const external = kind === 'external'; + document.getElementById('caseExternalCompanyWrap').classList.toggle('d-none', !external); + document.getElementById('caseContactCreateTitle').textContent = external ? 'Opret ekstern bruger' : 'Opret medarbejder'; + document.getElementById('caseContactCreateHelp').textContent = external + ? `Personen vises under ${selectedCustomer.name} og kan tilknyttes sit eget firma.` + : `Personen oprettes som kontakt hos ${selectedCustomer.name}.`; + bootstrap.Modal.getOrCreateInstance(document.getElementById('caseContactCreateModal')).show(); + } + + document.getElementById('caseContactCreateForm')?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!selectedCustomer) return; + const error = document.getElementById('caseContactCreateError'); + error.classList.add('d-none'); + const payload = { + first_name: document.getElementById('caseContactFirstName').value.trim(), + last_name: document.getElementById('caseContactLastName').value.trim(), + email: document.getElementById('caseContactEmail').value.trim() || null, + phone: document.getElementById('caseContactPhone').value.trim() || null, + role: document.getElementById('caseContactRole').value.trim() || null + }; + try { + const path = caseContactCreateKind === 'external' ? 'house-users' : 'contacts'; + if (caseContactCreateKind === 'external') payload.external_company_name = document.getElementById('caseExternalCompany').value.trim() || null; + const response = await fetch(`/api/v1/customers/${selectedCustomer.id}/${path}`, { + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || 'Kunne ikke oprette kontakt'); + bootstrap.Modal.getInstance(document.getElementById('caseContactCreateModal'))?.hide(); + await loadSelectedCustomerContacts(selectedCustomer.id); + await selectContact(data.id, `${data.first_name || ''} ${data.last_name || ''}`.trim()); + } catch (err) { + error.textContent = err.message; + error.classList.remove('d-none'); + } + }); + const caseTypeLabels = { ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi', admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it', diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py index 306b39a..ddbc19d 100644 --- a/app/modules/telefoni/backend/router.py +++ b/app/modules/telefoni/backend/router.py @@ -448,7 +448,12 @@ async def yealink_terminated( if duration_value is None: logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid) - existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,)) + existed = execute_query_single( + """SELECT id,bruger_id FROM telefoni_opkald + WHERE callid=%s AND ended_at IS NULL + ORDER BY started_at DESC, id DESC LIMIT 1""", + (resolved_callid,), + ) updated = TelefoniService.terminate_call(resolved_callid, duration_value) if not updated: logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid) @@ -648,6 +653,8 @@ def _register_completed_call_time(callid: str) -> Optional[int]: JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL LEFT JOIN users u ON u.user_id = t.bruger_id WHERE t.callid = %s + ORDER BY t.started_at DESC, t.id DESC + LIMIT 1 """, (callid,), ) @@ -1040,7 +1047,10 @@ async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate): COALESCE(m.last_payload, '{}'::jsonb) FROM mission_call_state m WHERE m.call_id = %s - ON CONFLICT (callid) DO NOTHING + AND NOT EXISTS ( + SELECT 1 FROM telefoni_opkald t + WHERE t.callid = m.call_id AND t.started_at = m.started_at + ) RETURNING id """, (data.callid.strip(),), @@ -1049,7 +1059,10 @@ async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate): call_id = int(promoted[0]["id"]) existing = {"id": call_id} else: - existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE callid = %s", (data.callid.strip(),)) + existing = execute_query_single( + "SELECT id FROM telefoni_opkald WHERE callid = %s ORDER BY started_at DESC, id DESC LIMIT 1", + (data.callid.strip(),), + ) if existing: call_id = int(existing["id"]) if not existing: diff --git a/app/modules/telefoni/backend/service.py b/app/modules/telefoni/backend/service.py index ea0cafb..967ced2 100644 --- a/app/modules/telefoni/backend/service.py +++ b/app/modules/telefoni/backend/service.py @@ -192,7 +192,7 @@ class TelefoniService: (callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) - ON CONFLICT (callid) + ON CONFLICT (callid) WHERE ended_at IS NULL DO UPDATE SET raw_payload = EXCLUDED.raw_payload, direction = EXCLUDED.direction, @@ -226,25 +226,41 @@ class TelefoniService: rows = execute_query( """ - INSERT INTO telefoni_opkald - (callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at) - VALUES - (%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW()) - ON CONFLICT (callid) - DO UPDATE SET - ended_at = COALESCE(telefoni_opkald.ended_at, NOW()), - duration_sec = COALESCE( - EXCLUDED.duration_sec, - CASE - WHEN telefoni_opkald.started_at IS NOT NULL - AND NOW() - telefoni_opkald.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' - THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int - ELSE NULL - END - ), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW() - RETURNING id + WITH completed AS ( + UPDATE telefoni_opkald + SET ended_at = NOW(), + duration_sec = COALESCE( + %s, + CASE + WHEN started_at IS NOT NULL + AND NOW() - started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' + THEN EXTRACT(EPOCH FROM (NOW() - started_at))::int + ELSE NULL + END + ), + lifecycle_status = 'completed', + termination_source = 'callback', + last_event_at = NOW() + WHERE id = ( + SELECT id + FROM telefoni_opkald + WHERE callid = %s AND ended_at IS NULL + ORDER BY started_at DESC, id DESC + LIMIT 1 + ) + RETURNING id + ), orphan AS ( + INSERT INTO telefoni_opkald + (callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at) + SELECT %s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW() + WHERE NOT EXISTS (SELECT 1 FROM completed) + RETURNING id + ) + SELECT id FROM completed + UNION ALL + SELECT id FROM orphan """, - (callid, duration_sec), + (duration_sec, callid, callid, duration_sec), ) return bool(rows) diff --git a/fremtidige planer/README.md b/fremtidige planer/README.md index c6f76f6..b45253d 100644 --- a/fremtidige planer/README.md +++ b/fremtidige planer/README.md @@ -9,6 +9,7 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men - [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol. - [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer. - [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning. +- [Sagsopretter: husets brugere, medarbejdere og eksterne](sag-opretter-husbrugere-og-eksterne.md) — kontakt-søgning og oprettelse, hvor huset forbliver kunde på sagen. ## Arbejdsgang diff --git a/fremtidige planer/sag-opretter-husbrugere-og-eksterne.md b/fremtidige planer/sag-opretter-husbrugere-og-eksterne.md new file mode 100644 index 0000000..627a472 --- /dev/null +++ b/fremtidige planer/sag-opretter-husbrugere-og-eksterne.md @@ -0,0 +1,175 @@ +# PLAN – Sagsopretter: husets brugere, medarbejdere og eksterne + +> **Status: Fremtidig plan – ikke implementeret.** +> +> Planen ændrer ikke eksisterende sager, kunder eller kontaktpersoner. Før +> udvikling kortlægges den nuværende sagsopretter, kontakt-søgning og +> kunde-/kontaktrelationer. + +## 1. Formål + +Når der oprettes en sag for eksempelvis et PFA-hus, er **huset/adressen** +kunden på sagen. Den person, som ringer eller bruger huset, skal registreres +som kontaktperson på sagen. + +Personen kan være: + +- medarbejder hos kunden +- husets bruger fra et andet firma +- ekstern, fx leverandør, håndværker eller gæst + +Sagen skal fortsat have huset som kunde, også når personen arbejder for et +andet firma. + +## 2. Arbejdsgang i sagsopretteren + +1. Brugeren vælger først kunde/hus og eventuelt lokation. +2. Feltet **Kontaktperson** søger mens der skrives på navn, telefon og e-mail. +3. Resultater viser navn, firma, rolle og eventuel tidligere tilknytning til + huset. +4. Brugeren vælger en eksisterende person, eller fortsætter med en af de to + handlinger nederst i resultatlisten: + - **Opret medarbejder** + - **Opret ekstern / husets bruger** +5. Den oprettede kontakt vælges automatisk på sagen. +6. Kunde og lokation på sagen må ikke ændres af kontaktoprettelsen. + +## 3. Opret medarbejder + +Formularen skal være kort: + +- navn +- telefon og/eller e-mail +- rolle eller afdeling, valgfrit + +Den nye person knyttes til den valgte kunde/hus som medarbejder og tilføjes +som kontaktperson på sagen. + +## 4. Opret ekstern / husets bruger + +Formularen skal indeholde: + +- navn +- telefon og/eller e-mail +- eget firma: vælg eksisterende eller opret nyt +- rolle ved huset, fx bruger, leverandør, håndværker eller gæst +- valgfri note + +Personen får både sin egen firmatilknytning og en særskilt tilknytning til +det valgte hus/lokation. Det betyder, at samme person senere kan findes via +navn, eget firma og hus. + +## 5. Datamodel og regler + +Før implementering kortlægges eksisterende tabeller og API'er for: + +- kontaktpersoner +- firma-/kunde-tilknytning +- sagens kontaktpersoner +- lokationer + +Hvis den nuværende model ikke kan repræsentere en ekstern bruger af et hus, +oprettes en selvstændig, bagudkompatibel relation: + +`kontakt_lokationsbrugere` + +Foreslåede felter: + +- `contact_id` +- `location_id` +- `customer_id` for huset +- `relation_type` (`employee`, `house_user`, `external_supplier`, `guest`) +- `company_id` for personens eget firma, valgfri +- `role`, `notes` +- `is_active`, oprettet/opdateret-tidspunkt + +En person må have flere aktive relationer til flere huse. Eksisterende +kontakt- og kundedata må ikke omskrives eller flyttes. + +### Firmaer i huset + +Et firma, der bruger et hus men ikke er BMC-kunde, skal registreres som en +organisation med typen `external_house_company`. + +Det skal have: + +- et tydeligt tag, fx **Firma i huset** +- feltet `is_bmc_customer = false` +- feltet `show_in_customer_list = false` +- relation til hovedkunden og den konkrete lokation/hus + +Det betyder, at firmaet kan have egen profil, egne brugere og egne kontaktdata +uden at optræde i den normale kundeliste eller blive valgt som fakturakunde. + +Den normale kundeliste viser kun BMC-kunder som standard. En eksplicit filter +**Vis firmaer i huse og eksterne organisationer** viser de skjulte firmaer, +når det er nødvendigt. + +Relationen skal være mange-til-mange, da ét firma kan bruge flere huse, og ét +hus kan rumme flere firmaer. + +## 5.1 Placering i brugerfladen + +På hovedkunden/huset tilføjes en fane: + +**Brugere i huset** + +Fanens to sektioner: + +1. **Personer** – medarbejdere, husets brugere, leverandører og gæster. +2. **Firmaer i huset** – eksterne firmaer med antal brugere og de lokationer, + de er knyttet til. + +På den konkrete lokation kan samme data vises som en mindre, filtreret +oversigt. Sagsopretteren søger først i personerne og firmaerne under det +valgte hus. + +## 6. Søgning og dubletkontrol + +Søgning skal prioritere: + +1. personer, som allerede er knyttet til det valgte hus +2. personer hos den valgte kunde +3. øvrige kendte kontakter + +Før oprettelse vises mulige dubletter ved samme e-mail eller telefon. Brugeren +skal kunne vælge den eksisterende kontakt i stedet for at oprette en ny. + +## 7. Visning på sagen + +Sagen skal vise: + +- kunde: huset/adressen +- lokation, hvis valgt +- kontaktpersonens navn +- personens eget firma, hvis det er et andet firma +- relation til huset, fx `Husets bruger` eller `Ekstern leverandør` + +Dette skal være tydeligt i både sagsdetalje og ved senere genåbning af sagen. + +## 8. Afgrænsning for første version + +Første version omfatter kun søgning, oprettelse og tilknytning af personer +under sagsoprettelse. Den må ikke ændre fakturakunde, eksisterende +kundehierarki, rettigheder eller historiske sager. + +## 9. Implementeringsrækkefølge + +1. Kortlæg nuværende kontakt- og sagsrelationer. +2. Beslut om eksisterende relationer er nok; opret kun ny migration ved behov. +3. Tilføj søge-API med kundens/husets relationer først. +4. Tilføj de to opret-flow i sagsopretteren. +5. Tilføj relationen for firmaer i huset og fanen **Brugere i huset**. +6. Tilføj automatisk valg af den nye kontakt på sagen. +7. Vis relationen tydeligt på sagsdetaljen. +8. Tilføj dubletkontrol og audit-log. + +## 10. Acceptkriterier + +- En PFA-lokation kan vælges som kunde på sagen. +- En bruger fra et andet firma kan oprettes uden at ændre sagens kunde. +- Personen kan findes igen ved navn, eget firma og hus. +- Medarbejder og ekstern kan oprettes fra samme kontakt-søgning. +- Firmaer i huset er skjult fra den normale kundeliste som standard. +- Firmaer i huset kan findes fra hovedkunden, lokationen og sagsopretteren. +- Eksisterende sager og kontakter virker uændret. diff --git a/migrations/1047_telefoni_reused_callids.sql b/migrations/1047_telefoni_reused_callids.sql new file mode 100644 index 0000000..e1ad30c --- /dev/null +++ b/migrations/1047_telefoni_reused_callids.sql @@ -0,0 +1,61 @@ +-- Yealink/PBX call IDs are reused after a phone or PBX lifecycle. +-- Keep historical calls and require uniqueness only while a call is active. + +DROP INDEX IF EXISTS ux_telefoni_opkald_callid; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_telefoni_opkald_active_callid + ON telefoni_opkald(callid) + WHERE ended_at IS NULL; + +-- Recover calls that were previously merged into an older row with the same +-- reused call ID. System events are written from the successful callback and +-- retain the callback metadata required for a read-safe reconstruction. +INSERT INTO telefoni_opkald ( + callid, bruger_id, direction, ekstern_nummer, intern_extension, + started_at, ended_at, duration_sec, raw_payload, + lifecycle_status, termination_source, last_event_at +) +SELECT + established.correlation_id, + established.user_id, + COALESCE(NULLIF(established.details->'payload'->>'direction', ''), 'inbound'), + NULLIF(established.details->>'external_number', ''), + NULLIF(established.details->>'extension', ''), + established.occurred_at AT TIME ZONE 'UTC', + terminated.occurred_at AT TIME ZONE 'UTC', + CASE + WHEN NULLIF(terminated.details->>'duration_sec', '') ~ '^[0-9]+$' + THEN (terminated.details->>'duration_sec')::INTEGER + WHEN terminated.occurred_at IS NOT NULL + THEN GREATEST(EXTRACT(EPOCH FROM (terminated.occurred_at - established.occurred_at))::INTEGER, 0) + ELSE NULL + END, + COALESCE(established.details->'payload', '{}'::jsonb), + CASE WHEN terminated.id IS NULL THEN 'active' ELSE 'completed' END, + CASE WHEN terminated.id IS NULL THEN NULL ELSE 'system_log_recovery' END, + COALESCE(terminated.occurred_at, established.occurred_at) +FROM system_event_log established +LEFT JOIN LATERAL ( + SELECT term.id, term.occurred_at, term.details + FROM system_event_log term + WHERE term.source = 'voip' + AND term.event_type = 'voip.terminated' + AND term.correlation_id = established.correlation_id + AND term.occurred_at >= established.occurred_at + ORDER BY term.occurred_at ASC, term.id ASC + LIMIT 1 +) terminated ON TRUE +WHERE established.source = 'voip' + AND established.event_type = 'voip.established' + AND NULLIF(TRIM(established.correlation_id), '') IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM telefoni_opkald current_call + WHERE current_call.callid = established.correlation_id + AND current_call.started_at BETWEEN + (established.occurred_at AT TIME ZONE 'UTC') - INTERVAL '5 minutes' + AND (established.occurred_at AT TIME ZONE 'UTC') + INTERVAL '5 minutes' + ); + +CREATE INDEX IF NOT EXISTS idx_telefoni_opkald_callid_started + ON telefoni_opkald(callid, started_at DESC); diff --git a/migrations/1048_location_access_files.sql b/migrations/1048_location_access_files.sql new file mode 100644 index 0000000..75c8f46 --- /dev/null +++ b/migrations/1048_location_access_files.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS locations_access_files ( + id SERIAL PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE, + original_filename VARCHAR(255) NOT NULL, + stored_filename VARCHAR(255) NOT NULL, + content_type VARCHAR(150), + file_size BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_locations_access_files_location + ON locations_access_files(location_id, created_at DESC) + WHERE deleted_at IS NULL; diff --git a/migrations/1049_location_access_file_comments.sql b/migrations/1049_location_access_file_comments.sql new file mode 100644 index 0000000..a6eb0c7 --- /dev/null +++ b/migrations/1049_location_access_file_comments.sql @@ -0,0 +1,2 @@ +ALTER TABLE locations_access_files + ADD COLUMN IF NOT EXISTS comment TEXT; diff --git a/migrations/1050_location_files.sql b/migrations/1050_location_files.sql new file mode 100644 index 0000000..fac969b --- /dev/null +++ b/migrations/1050_location_files.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS locations_files ( + id SERIAL PRIMARY KEY, + location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE, + original_filename VARCHAR(255) NOT NULL, + stored_filename VARCHAR(255) NOT NULL, + content_type VARCHAR(150), + file_size BIGINT NOT NULL DEFAULT 0, + comment TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_locations_files_location + ON locations_files(location_id, created_at DESC) WHERE deleted_at IS NULL; diff --git a/migrations/1051_house_users_and_external_companies.sql b/migrations/1051_house_users_and_external_companies.sql new file mode 100644 index 0000000..dcefa70 --- /dev/null +++ b/migrations/1051_house_users_and_external_companies.sql @@ -0,0 +1,35 @@ +ALTER TABLE customers + ADD COLUMN IF NOT EXISTS is_bmc_customer BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS show_in_customer_list BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE TABLE IF NOT EXISTS customer_house_organizations ( + id SERIAL PRIMARY KEY, + host_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + external_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + location_id INTEGER REFERENCES locations_locations(id) ON DELETE SET NULL, + relationship_type VARCHAR(40) NOT NULL DEFAULT 'house_user_company', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (host_customer_id, external_customer_id, location_id) +); + +CREATE TABLE IF NOT EXISTS contact_location_users ( + id SERIAL PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + host_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + location_id INTEGER REFERENCES locations_locations(id) ON DELETE SET NULL, + external_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + relation_type VARCHAR(40) NOT NULL DEFAULT 'house_user', + role VARCHAR(120), + notes TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_customer_house_organizations_host + ON customer_house_organizations(host_customer_id) WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_contact_location_users_host + ON contact_location_users(host_customer_id) WHERE is_active = TRUE; diff --git a/migrations/1052_sag_status_constraint_compatibility.sql b/migrations/1052_sag_status_constraint_compatibility.sql new file mode 100644 index 0000000..0809b43 --- /dev/null +++ b/migrations/1052_sag_status_constraint_compatibility.sql @@ -0,0 +1,24 @@ +-- Repair installations that still have the legacy sag_sager status constraint. +-- The case module manages its allowed statuses in settings; the database only +-- guarantees that a case status is present. +DO $$ +DECLARE + constraint_row RECORD; +BEGIN + FOR constraint_row IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = 'sag_sager' + AND c.contype = 'c' + AND c.conname IN ('sag_sager_status_check', 'sag_sager_status_nonempty_check') + LOOP + EXECUTE format('ALTER TABLE public.sag_sager DROP CONSTRAINT IF EXISTS %I', constraint_row.conname); + END LOOP; +END $$; + +ALTER TABLE public.sag_sager + ADD CONSTRAINT sag_sager_status_nonempty_check + CHECK (length(trim(status)) > 0); diff --git a/tests/test_telefoni_call_logging.py b/tests/test_telefoni_call_logging.py index fae8e08..7f77639 100644 --- a/tests/test_telefoni_call_logging.py +++ b/tests/test_telefoni_call_logging.py @@ -120,6 +120,24 @@ def test_stale_termination_duration_is_not_derived_from_current_time(monkeypatch assert "INTERVAL '12 hours'" in queries[0] +def test_reused_callids_only_conflict_while_the_call_is_active(): + service = Path("app/modules/telefoni/backend/service.py").read_text(encoding="utf-8") + migration = Path("migrations/1047_telefoni_reused_callids.sql").read_text(encoding="utf-8") + + assert "ON CONFLICT (callid) WHERE ended_at IS NULL" in service + assert "WHERE callid = %s AND ended_at IS NULL" in service + assert "DROP INDEX IF EXISTS ux_telefoni_opkald_callid" in migration + assert "ux_telefoni_opkald_active_callid" in migration + + +def test_callid_recovery_uses_system_log_without_overwriting_existing_current_calls(): + migration = Path("migrations/1047_telefoni_reused_callids.sql").read_text(encoding="utf-8") + + assert "system_event_log established" in migration + assert "system_log_recovery" in migration + assert "current_call.started_at BETWEEN" in migration + + def test_case_click_to_call_contract_tracks_case_contact_and_time(): schema = Path("app/modules/telefoni/backend/schemas.py").read_text(encoding="utf-8") router = Path("app/modules/telefoni/backend/router.py").read_text(encoding="utf-8")