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 +