feat(subscriptions): enhance subscription update logic to allow direct edits for drafts and add new endpoint for manual invoice processing

feat(ticket): update email integration to use new priority constants for ticket classification

feat(procurement): add procurement overview page with dynamic data loading and display

test(subscriptions): add tests for billing calendar to ensure correct invoice dates

feat(reminder): implement automated task lists with user-defined rules for reminders

feat(migrations): create tables for managing delefiber product prices and mobile recorder provisioning history

test(mobile_recorder): add tests for provisioning mobile recorders to ensure correct asset creation and updates
This commit is contained in:
Christian 2026-09-07 19:05:58 +02:00
parent 1cfe5aee76
commit 9ca562745a
31 changed files with 1776 additions and 82 deletions

View File

@ -3,6 +3,7 @@
# ===================================================== # =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk HUB_BASE_URL=https://hub.bmcnetworks.dk
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
# Database credentials (bruges af docker-compose) # Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub POSTGRES_USER=bmc_hub

View File

@ -1125,7 +1125,7 @@ def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
""" """
SELECT id, customer_id, address, monthly_cost, technology, connection_type, SELECT id, customer_id, address, monthly_cost, technology, connection_type,
circuit_number, speed_mbps, download_mbps, upload_mbps, status, circuit_number, speed_mbps, download_mbps, upload_mbps, status,
allocation_model, value_type, value_label allocation_model, value_type, value_label, is_manual_shared
FROM internet_connections_connections FROM internet_connections_connections
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%' AND provider ILIKE 'GlobalConnect%%'
@ -1264,6 +1264,19 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
) )
if existing: if existing:
# Delefiber/BMCnet classification is internal operational data. A
# supplier invoice may update speed and cost, but must never undo a
# deliberate manual shared/delefiber designation.
preserve_manual_classification = bool(existing.get("is_manual_shared"))
resolved_allocation_model = (
existing.get("allocation_model") if preserve_manual_classification
else ("shared" if is_shared_candidate else "dedicated")
)
resolved_value_type = (
existing.get("value_type") if preserve_manual_classification
else shared_value_type
)
resolved_value_label = existing.get("value_label") if preserve_manual_classification else None
updated_snapshot = { updated_snapshot = {
"customer_id": existing.get("customer_id"), "customer_id": existing.get("customer_id"),
"address": service_address, "address": service_address,
@ -1275,9 +1288,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
"download_mbps": download_mbps, "download_mbps": download_mbps,
"upload_mbps": upload_mbps, "upload_mbps": upload_mbps,
"status": target_status, "status": target_status,
"allocation_model": "shared" if is_shared_candidate else "dedicated", "allocation_model": resolved_allocation_model,
"value_type": shared_value_type, "value_type": resolved_value_type,
"value_label": None, "value_label": resolved_value_label,
} }
update_payload = ( update_payload = (
connection_name, connection_name,
@ -1292,9 +1305,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
download_mbps, download_mbps,
upload_mbps, upload_mbps,
note_text, note_text,
"shared" if is_shared_candidate else "dedicated", resolved_allocation_model,
shared_value_type, resolved_value_type,
None, resolved_value_label,
existing["id"], existing["id"],
) )
execute_update( execute_update(

View File

@ -37,6 +37,10 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk" HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
# Elnet supplier lookup # Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api" ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12 ELNET_TIMEOUT_SECONDS: int = 12

View File

@ -891,7 +891,7 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt"> <a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt <i class="bi bi-chat-left-text"></i>Kommunikation
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -1241,11 +1241,14 @@
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div> <div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div> </div>
<!-- Kontakt Tab --> <!-- Kommunikation Tab -->
<div class="tab-pane fade" id="kontakt"> <div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontakt historik</h5> <div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter"> <h5 class="fw-bold mb-0">Kommunikationshistorik</h5>
<small class="text-muted">Opkald og SMSer med kunden</small>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kommunikationsfilter">
<button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button> <button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button> <button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button> <button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>

View File

@ -15,6 +15,7 @@ from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService from app.services.email_processor_service import EmailProcessorService
from app.services.email_service import EmailService
from app.services.email_workflow_service import email_workflow_service from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier from app.services.simple_classifier import simple_classifier
@ -2262,6 +2263,37 @@ async def reprocess_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/recover-attachments")
async def recover_email_attachments(email_id: int):
"""Recover a missing Graph attachment, then run the normal email workflow.
Intended for the old metadata-only attachment imports. It is safe to retry:
the normal invoice checksum and workflow safeguards remain in force.
"""
try:
recovery = await EmailService().recover_graph_attachments(email_id)
if not recovery.get("success"):
raise HTTPException(status_code=409, detail=recovery.get("reason", "Could not recover attachments"))
email_rows = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,)
)
if not email_rows:
raise HTTPException(status_code=404, detail="Email not found after recovery")
processing = await EmailProcessorService().process_single_email(email_rows[0])
return {
"success": True,
**recovery,
"workflows_executed": processing.get("workflows_executed", 0),
"awaiting_user_action": processing.get("awaiting_user_action", False),
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Error recovering email attachments for %s", email_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/process") @router.post("/emails/process")
async def process_emails( async def process_emails(
limit: Optional[int] = Query(default=None, ge=1, le=500), limit: Optional[int] = Query(default=None, ge=1, le=500),
@ -2379,10 +2411,10 @@ async def upload_emails(files: List[UploadFile] = File(...)):
logger.info(f"💾 Saved to database with ID: {email_id}") logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity # Log activity
activity_logger.log_fetched( await activity_logger.log_fetched(
email_id=email_id, email_id=email_id,
source="manual_upload", source="manual_upload",
metadata={"filename": file.filename} message_id=email_data.get("message_id", file.filename)
) )
# Auto-classify # Auto-classify

View File

@ -29,6 +29,8 @@ async def check_reminders():
try: try:
logger.info("🔔 Checking for pending reminders...") logger.info("🔔 Checking for pending reminders...")
from app.modules.sag.backend.reminders import process_task_list_rules
await process_task_list_rules()
# Step 1: Process queued trigger events (status changes) # Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue() queue_count = await _process_reminder_queue()

View File

@ -8,6 +8,7 @@ Runs daily at 04:00
import logging import logging
from datetime import datetime, date from datetime import datetime, date
import json import json
from typing import Optional, Sequence
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection from app.core.database import execute_query, get_db_connection
@ -20,7 +21,7 @@ from app.services.subscription_billing_calendar import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def process_subscriptions(): async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None):
""" """
Main job: Process subscriptions due for invoicing. Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today - Find active subscriptions where next_invoice_date <= today
@ -108,10 +109,19 @@ async def process_subscriptions():
SELECT 1 FROM subscription_billing_runs br SELECT 1 FROM subscription_billing_runs br
WHERE br.subscription_id = s.id AND br.period_start = s.period_start WHERE br.subscription_id = s.id AND br.period_start = s.period_start
) )
"""
params = []
if subscription_ids is not None:
selected_ids = sorted({int(item) for item in subscription_ids})
if not selected_ids:
return
query += " AND s.id = ANY(%s)"
params.append(selected_ids)
query += """
ORDER BY s.next_invoice_date, s.id ORDER BY s.next_invoice_date, s.id
""" """
subscriptions = execute_query(query) subscriptions = execute_query(query, tuple(params))
if not subscriptions: if not subscriptions:
logger.info("✅ No subscriptions due for invoicing") logger.info("✅ No subscriptions due for invoicing")

View File

@ -971,6 +971,10 @@ def build_bottom_bar_state(
unassigned_open_cases = get_unassigned_open_cases(limit=8) unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10) recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10) notes_summary = get_user_notes_summary(user_id, limit=10)
procurement_attention = execute_query_single(
"""SELECT COUNT(*)::int AS count FROM sag_salgsvarer
WHERE type = 'purchase' AND status = 'draft'"""
) or {"count": 0}
urgent_cases = execute_query( urgent_cases = execute_query(
""" """
@ -1155,6 +1159,10 @@ def build_bottom_bar_state(
"list": unassigned_open_cases.get("items") or [], "list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {}, "filter_meta": unassigned_open_cases.get("filter_meta") or {},
}, },
"procurement": {
"to_order": int(procurement_attention.get("count") or 0),
"route": "/procurement",
},
"timer": { "timer": {
"active_count": 1 if timer.get("active") else 0, "active_count": 1 if timer.get("active") else 0,
"list": timer_list, "list": timer_list,

View File

@ -8,11 +8,164 @@ from psycopg2.extras import Json
from datetime import datetime, date from datetime import datetime, date
import os import os
import uuid import uuid
import secrets
from fastapi import Header, status
from pydantic import BaseModel, Field
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
class MobileRecorderProvisionRequest(BaseModel):
"""Payload posted by the Apple Configurator cfgutil provisioning script."""
name: str = Field(min_length=1, max_length=120)
asset_type: str = "mobile_recorder"
manufacturer: str = "Apple"
recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
model: Optional[str] = Field(default=None, max_length=100)
device_type: Optional[str] = Field(default=None, max_length=100)
serial_number: str = Field(min_length=1, max_length=100)
udid: Optional[str] = Field(default=None, max_length=160)
ecid: Optional[str] = Field(default=None, max_length=160)
imei: Optional[str] = Field(default=None, max_length=40)
wifi_mac: Optional[str] = Field(default=None, max_length=40)
os: str = "iOS"
os_version: Optional[str] = Field(default=None, max_length=40)
supervised: bool = False
status: str = "ready"
def _provisioning_token_or_401(
authorization: Optional[str], x_provisioning_token: Optional[str],
) -> None:
"""Authenticate a headless provisioning client without accepting user JWTs."""
expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
if not expected:
logger.error("Mobile Recorder provisioning was called but no service token is configured")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Provisioning endpoint is disabled: service token is not configured",
)
bearer = (authorization or "").strip()
supplied = (x_provisioning_token or "").strip()
if not supplied and bearer.lower().startswith("bearer "):
supplied = bearer[7:].strip()
if not supplied or not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provisioning token",
headers={"WWW-Authenticate": "Bearer"},
)
def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
return str(value).strip() if value is not None and str(value).strip() else None
@router.post("/assets/provision", status_code=status.HTTP_200_OK)
async def provision_mobile_recorder(
payload: MobileRecorderProvisionRequest,
authorization: Optional[str] = Header(default=None),
x_provisioning_token: Optional[str] = Header(default=None),
):
"""Create or update an Apple BMC Mobile Recorder by its physical serial number."""
_provisioning_token_or_401(authorization, x_provisioning_token)
if payload.asset_type != "mobile_recorder":
raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
if payload.status != "ready":
raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
serial_number = _clean_provisioning_value(payload.serial_number)
if not serial_number:
raise HTTPException(status_code=422, detail="serial_number is required")
manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
recorder_name = _clean_provisioning_value(payload.name)
mobile_specs = {
"recorder_number": payload.recorder_number,
"name": recorder_name,
"udid": _clean_provisioning_value(payload.udid),
"ecid": _clean_provisioning_value(payload.ecid),
"imei": _clean_provisioning_value(payload.imei),
"wifi_mac": _clean_provisioning_value(payload.wifi_mac),
"os": _clean_provisioning_value(payload.os) or "iOS",
"os_version": _clean_provisioning_value(payload.os_version),
"supervised": bool(payload.supervised),
"provisioning_status": "ready",
"source": "apple_configurator",
}
existing = execute_query(
"""SELECT id, hardware_specs FROM hardware_assets
WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
ORDER BY id LIMIT 2""",
(serial_number,),
) or []
if len(existing) > 1:
raise HTTPException(
status_code=409,
detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
)
prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
if isinstance(prior_specs, str):
try:
prior_specs = json.loads(prior_specs)
except (TypeError, ValueError):
prior_specs = {}
if not isinstance(prior_specs, dict):
prior_specs = {}
prior_specs["mobile_recorder"] = mobile_specs
if existing:
asset_id = int(existing[0]["id"])
rows = execute_query(
"""UPDATE hardware_assets
SET asset_type = 'mobile_recorder', brand = %s,
model = COALESCE(%s, model), internal_asset_id = %s,
recorder_number = %s, status = 'ready', hardware_specs = %s,
last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id""",
(manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
)
action = "updated"
else:
rows = execute_query(
"""INSERT INTO hardware_assets
(asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id""",
(manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
)
action = "created"
asset_id = int(rows[0]["id"])
execute_query(
"""INSERT INTO hardware_ownership_history
(hardware_id, owner_type, start_date, notes)
VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
(asset_id,),
fetch=False,
)
if not rows:
raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
execute_query(
"""INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
VALUES (%s, %s, %s)""",
(asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
fetch=False,
)
logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]: def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None: if payload is None:
return None return None

View File

@ -125,7 +125,7 @@ def ensure_external_change_case(
else: else:
row = execute_query_single( row = execute_query_single(
"""INSERT INTO sag_sager """INSERT INTO sag_sager
(titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id) (titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id)
VALUES (%s,%s,'indkøb','åben',%s,%s,1) RETURNING id""", VALUES (%s,%s,'indkøb','åben',%s,%s,1) RETURNING id""",
(title, description, case_customer_id, _economy_group_id()), (title, description, case_customer_id, _economy_group_id()),
) )
@ -166,3 +166,38 @@ def ensure_external_change_case(
except Exception: except Exception:
logger.exception("Could not persist failed internet change-case audit") logger.exception("Could not persist failed internet change-case audit")
return {"case_id": None, "created": False, "changes": relevant, "error": str(exc)} return {"case_id": None, "created": False, "changes": relevant, "error": str(exc)}
def retry_failed_change_cases(source_type: str, source_key: str) -> dict[str, int]:
"""Retry audit rows after a transient case-creation failure.
Imports are intentionally idempotent, so a repeated invoice normally does
not apply connection data again. Failed audit rows must nevertheless be
repairable once the underlying case service has been fixed.
"""
rows = execute_query(
"""SELECT audit.connection_id, audit.source_label, audit.source_url, audit.changes,
ic.name, ic.circuit_number, ic.provider, ic.customer_id
FROM internet_connection_change_cases audit
JOIN internet_connections_connections ic ON ic.id = audit.connection_id
WHERE audit.source_type=%s AND audit.source_key=%s
AND audit.sag_id IS NULL AND audit.last_error IS NOT NULL
AND ic.deleted_at IS NULL""",
(source_type, source_key),
) or []
repaired = 0
failed = 0
for row in rows:
result = ensure_external_change_case(
connection_id=int(row["connection_id"]), source_type=source_type, source_key=source_key,
source_label=str(row.get("source_label") or source_key), changes=row.get("changes") or {},
connection_name=str(row.get("name") or "Internetforbindelse"),
reference=str(row.get("circuit_number") or ""),
provider=str(row.get("provider") or ""), owner_customer_id=row.get("customer_id"),
source_url=row.get("source_url"),
)
if result.get("case_id"):
repaired += 1
else:
failed += 1
return {"repaired": repaired, "failed": failed}

View File

@ -1248,7 +1248,18 @@ class QuickBmcnetCreatePayload(BaseModel):
address: Optional[str] = None address: Optional[str] = None
billing_interval: str = "monthly" billing_interval: str = "monthly"
billing_day: int = 1 billing_day: int = 1
billing_schedule_type: str = "fixed_day"
billing_direction: str = "forward"
advance_months: int = 1
billing_lead_months: int = 0
first_invoice_policy: str = "start_date"
start_date: date start_date: date
period_start: Optional[date] = None
first_full_period_start: Optional[date] = None
end_date: Optional[date] = None
notice_period_days: int = 30
binding_months: int = 0
binding_start_date: Optional[date] = None
internet_product_id: int internet_product_id: int
internet_unit_price: Optional[float] = None internet_unit_price: Optional[float] = None
ip_product_id: Optional[int] = None ip_product_id: Optional[int] = None
@ -1259,6 +1270,12 @@ class QuickBmcnetCreatePayload(BaseModel):
mark_gateway: bool = False mark_gateway: bool = False
class DelefiberProductPricePayload(BaseModel):
product_id: int
monthly_price: float
notes: Optional[str] = None
def _load_subscription(subscription_id: int) -> Dict[str, Any]: def _load_subscription(subscription_id: int) -> Dict[str, Any]:
subscription = execute_query_single( subscription = execute_query_single(
""" """
@ -3023,6 +3040,64 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc
release_db_connection(conn) release_db_connection(conn)
@router.get("/internet-connections/{connection_id}/product-prices", response_model=List[dict])
async def list_delefiber_product_prices(connection_id: int):
head = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL",
(connection_id,),
)
if not head:
raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet")
return execute_query(
"""SELECT price.id, price.product_id, price.monthly_price, price.notes, price.is_active,
price.updated_at, product.name AS product_name, product.sales_price AS product_sales_price
FROM internet_connections_delefiber_product_prices price
JOIN products product ON product.id=price.product_id AND product.deleted_at IS NULL
WHERE price.connection_id=%s AND price.is_active=true
ORDER BY product.name""",
(connection_id,),
) or []
@router.post("/internet-connections/{connection_id}/product-prices", response_model=dict)
async def upsert_delefiber_product_price(connection_id: int, payload: DelefiberProductPricePayload):
head = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL",
(connection_id,),
)
if not head:
raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet")
product = execute_query_single("SELECT id FROM products WHERE id=%s AND deleted_at IS NULL", (payload.product_id,))
if not product:
raise HTTPException(status_code=404, detail="Produktet blev ikke fundet")
if payload.monthly_price < 0:
raise HTTPException(status_code=400, detail="Prisen må ikke være negativ")
row = execute_query_single(
"""INSERT INTO internet_connections_delefiber_product_prices
(connection_id,product_id,monthly_price,notes,is_active)
VALUES (%s,%s,%s,%s,true)
ON CONFLICT (connection_id,product_id) DO UPDATE
SET monthly_price=EXCLUDED.monthly_price, notes=EXCLUDED.notes,
is_active=true, updated_at=NOW()
RETURNING id,connection_id,product_id,monthly_price,notes,is_active,updated_at""",
(connection_id, payload.product_id, payload.monthly_price, (payload.notes or '').strip() or None),
)
return dict(row)
@router.delete("/internet-connections/{connection_id}/product-prices/{product_id}", response_model=dict)
async def remove_delefiber_product_price(connection_id: int, product_id: int):
updated = execute_query(
"""UPDATE internet_connections_delefiber_product_prices
SET is_active=false, updated_at=NOW()
WHERE connection_id=%s AND product_id=%s AND is_active=true""",
(connection_id, product_id), fetch=False,
)
if not updated:
raise HTTPException(status_code=404, detail="Prislinjen blev ikke fundet")
return {"deleted": True}
@router.post("/internet-connections/{connection_id}/bmcnet-connections") @router.post("/internet-connections/{connection_id}/bmcnet-connections")
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload): async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
head_row = execute_query_single( head_row = execute_query_single(
@ -3074,9 +3149,25 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
if internet_profile.get("kind") != "internet_access": if internet_profile.get("kind") != "internet_access":
raise HTTPException(status_code=400, detail="Det valgte internetprodukt er ikke et netværksprodukt") raise HTTPException(status_code=400, detail="Det valgte internetprodukt er ikke et netværksprodukt")
# A local delefiber price is the default offer at this address. An explicit
# value in the wizard remains a negotiated customer-specific override.
local_price_row = execute_query_single(
"""SELECT monthly_price FROM internet_connections_delefiber_product_prices
WHERE connection_id=%s AND product_id=%s AND is_active=true""",
(connection_id, int(payload.internet_product_id)),
)
resolved_internet_unit_price = (
float(payload.internet_unit_price)
if payload.internet_unit_price is not None
else float(local_price_row["monthly_price"])
if local_price_row and local_price_row.get("monthly_price") is not None
else float(internet_product.get("sales_price") or 0)
)
ip_product = None ip_product = None
ip_profile = {} ip_profile = {}
selected_ip_address = None selected_ip_address = None
resolved_ip_unit_price = None
if payload.ip_product_id: if payload.ip_product_id:
ip_product = product_map.get(int(payload.ip_product_id)) ip_product = product_map.get(int(payload.ip_product_id))
if not ip_product: if not ip_product:
@ -3131,6 +3222,20 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
elif payload.range_id or payload.ip_address_id: elif payload.range_id or payload.ip_address_id:
raise HTTPException(status_code=400, detail="Der er valgt IP-allokering uden et IP-produkt") raise HTTPException(status_code=400, detail="Der er valgt IP-allokering uden et IP-produkt")
if ip_product:
local_ip_price = execute_query_single(
"""SELECT monthly_price FROM internet_connections_delefiber_product_prices
WHERE connection_id=%s AND product_id=%s AND is_active=true""",
(connection_id, int(ip_product["id"])),
)
resolved_ip_unit_price = (
float(payload.ip_unit_price)
if payload.ip_unit_price is not None
else float(local_ip_price["monthly_price"])
if local_ip_price and local_ip_price.get("monthly_price") is not None
else float(ip_product.get("sales_price") or 0)
)
case_title = str(payload.case_title or "").strip() or f"BMCnet - {customer_name}" case_title = str(payload.case_title or "").strip() or f"BMCnet - {customer_name}"
description_lines = [ description_lines = [
f"Hovedforbindelse: {head.get('name') or connection_id}", f"Hovedforbindelse: {head.get('name') or connection_id}",
@ -3170,14 +3275,14 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
"product_id": int(internet_product["id"]), "product_id": int(internet_product["id"]),
"description": str(internet_product.get("short_description") or internet_product.get("name") or "").strip(), "description": str(internet_product.get("short_description") or internet_product.get("name") or "").strip(),
"quantity": 1, "quantity": 1,
"unit_price": float(payload.internet_unit_price if payload.internet_unit_price is not None else (internet_product.get("sales_price") or 0)), "unit_price": resolved_internet_unit_price,
}] }]
if ip_product: if ip_product:
line_items.append({ line_items.append({
"product_id": int(ip_product["id"]), "product_id": int(ip_product["id"]),
"description": str(ip_product.get("short_description") or ip_product.get("name") or "").strip(), "description": str(ip_product.get("short_description") or ip_product.get("name") or "").strip(),
"quantity": 1, "quantity": 1,
"unit_price": float(payload.ip_unit_price if payload.ip_unit_price is not None else (ip_product.get("sales_price") or 0)), "unit_price": resolved_ip_unit_price,
}) })
from app.subscriptions.backend.router import create_subscription as create_sag_subscription from app.subscriptions.backend.router import create_subscription as create_sag_subscription
@ -3186,7 +3291,18 @@ async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcne
"sag_id": int(created_case["id"]), "sag_id": int(created_case["id"]),
"billing_interval": payload.billing_interval, "billing_interval": payload.billing_interval,
"billing_day": int(payload.billing_day), "billing_day": int(payload.billing_day),
"billing_schedule_type": payload.billing_schedule_type,
"billing_direction": payload.billing_direction,
"advance_months": int(payload.advance_months),
"billing_lead_months": int(payload.billing_lead_months),
"first_invoice_policy": payload.first_invoice_policy,
"start_date": payload.start_date.isoformat(), "start_date": payload.start_date.isoformat(),
"period_start": (payload.period_start or payload.start_date).isoformat(),
"first_full_period_start": (payload.first_full_period_start.isoformat() if payload.first_full_period_start else None),
"end_date": (payload.end_date.isoformat() if payload.end_date else None),
"notice_period_days": int(payload.notice_period_days),
"binding_months": int(payload.binding_months),
"binding_start_date": (payload.binding_start_date or payload.period_start or payload.start_date).isoformat(),
"notes": wizard_notes, "notes": wizard_notes,
"line_items": line_items, "line_items": line_items,
}) })

View File

@ -654,6 +654,12 @@
<div id="pricingHistoryList"></div> <div id="pricingHistoryList"></div>
</div> </div>
<div class="detail-panel p-4 mb-4 d-none" id="delefiberProductPricesPanel">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h5 class="mb-0">BMCnet standardpriser</h5><div class="small text-muted">Priser gælder kun for denne delefiber/adresse.</div></div></div>
<div class="row g-2 mb-2"><div class="col-md-6"><select class="form-select" id="delefiberPriceProduct"></select></div><div class="col-md-3"><input class="form-control" id="delefiberPriceAmount" type="number" min="0" step="0.01" placeholder="Kr./md."></div><div class="col-md-3"><button class="btn btn-primary w-100" type="button" onclick="saveDelefiberProductPrice()">Gem pris</button></div></div>
<div id="delefiberProductPricesList" class="small"></div>
</div>
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel"> <div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div> <div>
@ -815,6 +821,7 @@
<label class="form-label">Noter</label> <label class="form-label">Noter</label>
<textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea> <textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea>
</div> </div>
<div class="col-12"><details class="border rounded-3 p-3"><summary class="fw-semibold">Periode og aftalevilkår <span class="text-muted fw-normal">— valgfrit</span></summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Periode start</label><input type="date" class="form-control" id="bmcnetWizardPeriodStart"></div><div class="col-md-4"><label class="form-label">Slutdato</label><input type="date" class="form-control" id="bmcnetWizardEndDate"></div><div class="col-md-4"><label class="form-label">Opsigelsesvarsel</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardNoticeDays" min="0" value="30"><span class="input-group-text">dage</span></div></div><div class="col-md-4"><label class="form-label">Faktureringsretning</label><select class="form-select" id="bmcnetWizardBillingDirection"><option value="forward">Forud</option><option value="backward">Bagud</option></select></div><div class="col-md-4"><label class="form-label">Perioder pr. faktura</label><input type="number" class="form-control" id="bmcnetWizardAdvanceMonths" min="1" value="1"></div><div class="col-md-4"><label class="form-label">Fakturér før perioden</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardLeadMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div><div class="col-md-4"><label class="form-label">Første faktura</label><select class="form-select" id="bmcnetWizardFirstInvoicePolicy"><option value="start_date">På startdato</option><option value="next_cycle">Ved næste cyklus</option></select></div><div class="col-md-4"><label class="form-label">Binding</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardBindingMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div></div></details></div>
</div> </div>
</div> </div>
</div> </div>
@ -1174,6 +1181,44 @@
)).join(''); )).join('');
} }
let delefiberProductPrices = [];
function isDelefiber(connection = currentConnection) {
return Boolean(connection && !connection.parent_id && (connection.is_shared_head || connection.allocation_model === 'shared'));
}
async function loadDelefiberProductPrices() {
const panel = document.getElementById('delefiberProductPricesPanel');
if (!panel || !isDelefiber()) { panel?.classList.add('d-none'); return; }
panel.classList.remove('d-none');
const select = document.getElementById('delefiberPriceProduct');
const priceProducts = bmcnetWizardProducts.filter((product) => ['internet_access', 'ip_allocation'].includes(parseProductAttributes(product.attributes_json)?.network?.kind));
select.innerHTML = '<option value="">Vælg produkt</option>' + priceProducts.map((product) => `<option value="${product.id}">${escapeHtml(product.name || '-')} · global ${formatDKK(product.sales_price || 0)}</option>`).join('');
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`);
if (!response.ok) throw new Error('Kunne ikke hente standardpriser');
delefiberProductPrices = await response.json();
const list = document.getElementById('delefiberProductPricesList');
list.innerHTML = delefiberProductPrices.length ? delefiberProductPrices.map((row) => `<div class="d-flex align-items-center gap-2 border-top py-2"><strong class="flex-grow-1">${escapeHtml(row.product_name || '-')}</strong><span>${formatDKK(row.monthly_price || 0)}/md.</span><button class="btn btn-sm btn-outline-danger" onclick="deleteDelefiberProductPrice(${row.product_id})" title="Fjern"><i class="bi bi-x-lg"></i></button></div>`).join('') : '<div class="text-muted py-2">Ingen lokale standardpriser endnu.</div>';
} catch (error) { document.getElementById('delefiberProductPricesList').innerHTML = `<div class="text-danger">${escapeHtml(error.message)}</div>`; }
}
async function saveDelefiberProductPrice() {
const productId = Number(document.getElementById('delefiberPriceProduct').value || 0);
const amount = document.getElementById('delefiberPriceAmount').value;
if (!productId || amount === '') { document.getElementById('detailSaveFeedback').textContent = 'Vælg produkt og pris.'; return; }
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId,monthly_price:Number(amount)})});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke gemme standardpris.'); return; }
document.getElementById('delefiberPriceAmount').value = '';
await loadDelefiberProductPrices();
}
async function deleteDelefiberProductPrice(productId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices/${productId}`, {method:'DELETE'});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke fjerne standardpris.'); return; }
await loadDelefiberProductPrices();
}
function getSharedHeadOptions() { function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => !item?.parent_id); return (connectionOptions || []).filter((item) => !item?.parent_id);
} }
@ -1276,7 +1321,7 @@
: await loadRangesForSharedHead(headId); : await loadRangesForSharedHead(headId);
const availableRanges = filterAvailableBmcnetRanges(ranges); const availableRanges = filterAvailableBmcnetRanges(ranges);
rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => ` rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => `
<option value="${range.id}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option> <option value="${range.id}" data-prefix="${String(range.cidr || '').split('/')[1] || ''}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
`).join('')}`; `).join('')}`;
hint.textContent = availableRanges.length hint.textContent = availableRanges.length
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.` ? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
@ -1289,6 +1334,7 @@
const canCreateBmcnet = Boolean(connection && !connection.parent_id); const canCreateBmcnet = Boolean(connection && !connection.parent_id);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet); document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return; if (!canCreateBmcnet) return;
const today = new Date().toISOString().slice(0, 10);
document.getElementById('bmcnetWizardCustomerLookup').value = ''; document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = ''; document.getElementById('bmcnetWizardCustomerId').value = '';
@ -1297,7 +1343,15 @@
document.getElementById('bmcnetWizardAddress').value = connection.address || ''; document.getElementById('bmcnetWizardAddress').value = connection.address || '';
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly'; document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
document.getElementById('bmcnetWizardBillingDay').value = '1'; document.getElementById('bmcnetWizardBillingDay').value = '1';
document.getElementById('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10); document.getElementById('bmcnetWizardPeriodStart').value = today;
document.getElementById('bmcnetWizardEndDate').value = '';
document.getElementById('bmcnetWizardNoticeDays').value = '30';
document.getElementById('bmcnetWizardBillingDirection').value = 'forward';
document.getElementById('bmcnetWizardAdvanceMonths').value = '1';
document.getElementById('bmcnetWizardLeadMonths').value = '0';
document.getElementById('bmcnetWizardFirstInvoicePolicy').value = 'start_date';
document.getElementById('bmcnetWizardBindingMonths').value = '0';
document.getElementById('bmcnetWizardStartDate').value = today;
document.getElementById('bmcnetWizardInternetProduct').value = ''; document.getElementById('bmcnetWizardInternetProduct').value = '';
document.getElementById('bmcnetWizardIpProduct').value = ''; document.getElementById('bmcnetWizardIpProduct').value = '';
document.getElementById('bmcnetWizardInternetPrice').value = ''; document.getElementById('bmcnetWizardInternetPrice').value = '';
@ -1332,7 +1386,21 @@
input.value = ''; input.value = '';
return; return;
} }
input.value = option.dataset.price || ''; const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value));
input.value = local ? Number(local.monthly_price || 0) : (option.dataset.price || '');
}
function selectIpProductForPrefix(prefixLength) {
const select = document.getElementById('bmcnetWizardIpProduct');
if (!select || !prefixLength) return;
const product = bmcnetWizardProducts.find((item) => {
const network = parseProductAttributes(item.attributes_json)?.network || {};
return network.kind === 'ip_allocation' && Number(network.ip_prefix_length) === Number(prefixLength);
});
if (!product) return;
select.value = String(product.id);
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
refreshBmcnetWizardAllocationMode();
} }
async function createBmcnetConnection() { async function createBmcnetConnection() {
@ -1357,7 +1425,15 @@
address: document.getElementById('bmcnetWizardAddress').value.trim() || null, address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly', billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1), billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1),
billing_direction: document.getElementById('bmcnetWizardBillingDirection').value || 'forward',
advance_months: Number(document.getElementById('bmcnetWizardAdvanceMonths').value || 1),
billing_lead_months: Number(document.getElementById('bmcnetWizardLeadMonths').value || 0),
first_invoice_policy: document.getElementById('bmcnetWizardFirstInvoicePolicy').value || 'start_date',
start_date: document.getElementById('bmcnetWizardStartDate').value, start_date: document.getElementById('bmcnetWizardStartDate').value,
period_start: document.getElementById('bmcnetWizardPeriodStart').value || null,
end_date: document.getElementById('bmcnetWizardEndDate').value || null,
notice_period_days: Number(document.getElementById('bmcnetWizardNoticeDays').value || 0),
binding_months: Number(document.getElementById('bmcnetWizardBindingMonths').value || 0),
internet_product_id: internetProductId, internet_product_id: internetProductId,
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== '' internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
? Number(document.getElementById('bmcnetWizardInternetPrice').value) ? Number(document.getElementById('bmcnetWizardInternetPrice').value)
@ -1604,6 +1680,7 @@
renderConnectionCases(cases); renderConnectionCases(cases);
renderRelationGrid(connection); renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren); renderBmcnetChildren(connection, currentBmcnetChildren);
await loadDelefiberProductPrices();
renderContractsOverview(contracts); renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts); renderCrossFieldPorts(crossFieldPorts);
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`; const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
@ -2498,6 +2575,14 @@
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice'); syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
await refreshBmcnetWizardAllocationMode(); await refreshBmcnetWizardAllocationMode();
}); });
document.getElementById('bmcnetWizardRangeSelect').addEventListener('change', (event) => {
const option = event.target.options[event.target.selectedIndex];
selectIpProductForPrefix(option?.dataset?.prefix);
});
document.getElementById('bmcnetWizardIpSelect').addEventListener('change', () => {
// A single selected public address is sold as a Static WAN IP.
if (document.getElementById('bmcnetWizardIpSelect').value) selectIpProductForPrefix(32);
});
document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => { document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
await refreshBmcnetWizardAllocationMode(); await refreshBmcnetWizardAllocationMode();
}); });

View File

@ -5,11 +5,13 @@ CRUD operations, user preferences, snooze/dismiss functionality
import logging import logging
from typing import List, Optional from typing import List, Optional
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, status, Depends, Request from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert from app.core.database import execute_query, execute_insert
from app.core.config import settings
from app.core.auth_dependencies import require_any_permission from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service from app.services.reminder_notification_service import reminder_notification_service
@ -19,6 +21,94 @@ router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view") case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit") case_edit_access = require_any_permission("cases.edit", "tickets.edit")
def _ensure_task_list_rules():
execute_query("""CREATE TABLE IF NOT EXISTS reminder_task_list_rules (
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL,
times_json JSONB NOT NULL DEFAULT '[]'::jsonb, include_groups BOOLEAN NOT NULL DEFAULT true,
notify_mattermost BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, last_sent_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)""", fetch=False)
execute_query("ALTER TABLE reminder_task_list_rules ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP", fetch=False)
def _task_list(user_id: int, include_groups: bool):
group_filter = "OR s.assigned_group_id IN (SELECT group_id FROM user_groups WHERE user_id=%s)" if include_groups else ""
params = (user_id, user_id) if include_groups else (user_id,)
return execute_query(f"""SELECT s.id, s.titel, s.status, s.priority, s.deadline, c.name AS customer_name
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) NOT IN ('lukket','løst','closed','resolved','udsat','deferred')
AND (s.ansvarlig_bruger_id=%s {group_filter})
ORDER BY s.deadline NULLS LAST, s.updated_at DESC LIMIT 50""", params) or []
async def _send_task_list(rule, user_id: int):
tasks = _task_list(user_id, bool(rule.get('include_groups')))
if not tasks: return {'sent': False, 'message': 'Ingen åbne sager at sende'}
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").rstrip('/')
def cell(value, fallback=''):
value = str(value or fallback).replace('|', '\\|').replace('\n', ' ')
return value[:120]
lines = [
'| Sag | Kunde | Status | Prioritet | Deadline |',
'| :-- | :-- | :-- | :-- | :-- |',
]
for row in tasks:
title = cell(row.get('titel'), 'Uden titel')
link = f"[#{row['id']} · {title}]({base_url}/sag/{row['id']}/v3)"
deadline = row.get('deadline')
if hasattr(deadline, 'strftime'):
deadline = deadline.strftime('%d.%m.%Y')
lines.append(
f"| {link} | {cell(row.get('customer_name'))} | {cell(row.get('status'))} "
f"| {cell(row.get('priority'))} | {cell(deadline)} |"
)
result = await reminder_notification_service.send_reminder(
reminder_id=0, sag_id=int(tasks[0]['id']), case_title='Opgaveliste', customer_name=None,
reminder_title=rule['title'], reminder_message='\n'.join(lines), recipient_user_ids=[user_id],
recipient_emails=[], priority='normal', notify_mattermost=bool(rule.get('notify_mattermost')),
notify_email=False, notify_frontend=True, override_user_preferences=True)
return {'sent': bool(result.get('success')), 'count': len(tasks), 'result': result}
async def process_task_list_rules():
_ensure_task_list_rules()
now = datetime.now()
window_start = now - timedelta(minutes=5)
for rule in execute_query("SELECT * FROM reminder_task_list_rules WHERE is_active=true") or []:
times = rule.get('times_json') or []
if isinstance(times, str): times = json.loads(times)
for scheduled_time in times:
try:
hour, minute = (int(value) for value in scheduled_time.split(':', 1))
scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
except (AttributeError, TypeError, ValueError):
logger.warning("Ignoring invalid task-list schedule %r for rule %s", scheduled_time, rule['id'])
continue
last_sent = rule.get('last_sent_at')
if window_start < scheduled_at <= now and (not last_sent or last_sent < scheduled_at):
await _send_task_list(rule, int(rule['user_id']))
execute_query('UPDATE reminder_task_list_rules SET last_sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s',(rule['id'],),fetch=False)
break
@router.get('/api/v1/reminder-task-rules')
async def list_task_rules(request: Request):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
return execute_query('SELECT * FROM reminder_task_list_rules WHERE user_id=%s ORDER BY id DESC',(user_id,)) or []
@router.post('/api/v1/reminder-task-rules')
async def save_task_rule(request: Request, data: dict):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
title=str(data.get('title') or 'Min opgaveliste').strip(); times=[t for t in (data.get('times') or []) if isinstance(t,str)]
if not times: raise HTTPException(status_code=400, detail='Vælg mindst ét tidspunkt')
rows=execute_query("""INSERT INTO reminder_task_list_rules(user_id,title,times_json,include_groups,notify_mattermost)
VALUES(%s,%s,%s::jsonb,%s,%s) RETURNING *""",(user_id,title,json.dumps(times),bool(data.get('include_groups',True)),bool(data.get('notify_mattermost',True))))
return rows[0]
@router.post('/api/v1/reminder-task-rules/{rule_id}/send-now')
async def send_task_rule_now(rule_id:int, request:Request):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
rule=execute_query("SELECT * FROM reminder_task_list_rules WHERE id=%s AND user_id=%s",(rule_id,user_id))
if not rule: raise HTTPException(status_code=404, detail='Reglen findes ikke')
return await _send_task_list(rule[0],user_id)
# ============================================================================ # ============================================================================
# Helper Functions # Helper Functions

View File

@ -89,6 +89,23 @@ def _get_user_id_from_request(request: Request) -> int:
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter") raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
def _ensure_case_internet_connection_tag(sag_id: int) -> None:
"""Mark a case as internet-related without creating duplicate tags."""
existing = execute_query_single(
"""SELECT id FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
LIMIT 1""",
(sag_id,),
)
if not existing:
execute_query(
"INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
(sag_id,),
fetch=False,
)
def _normalize_case_status(status_value: Optional[str]) -> str: def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = [] allowed_statuses = []
seen = set() seen = set()
@ -1019,8 +1036,8 @@ async def create_sag(request: Request, data: dict):
_validate_group_id(assigned_group_id) _validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket" case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline") if case_type == "pipeline" else None pipeline = data.get("pipeline")
order_items = data.get("order_items") if case_type == "ordre" else [] order_items = data.get("order_items", [])
raw_contact_ids = data.get("contact_ids") or [] raw_contact_ids = data.get("contact_ids") or []
if not isinstance(raw_contact_ids, list): if not isinstance(raw_contact_ids, list):
raise HTTPException(status_code=400, detail="contact_ids skal være en liste") raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
@ -1158,6 +1175,16 @@ async def create_sag(request: Request, data: dict):
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""", VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(result["id"], connection_id, current_user_id), (result["id"], connection_id, current_user_id),
) )
cursor.execute(
"""INSERT INTO sag_tags (sag_id, tag_navn)
SELECT %s, 'internet forbindelse'
WHERE NOT EXISTS (
SELECT 1 FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
)""",
(result["id"], result["id"]),
)
if telefoni_opkald_id: if telefoni_opkald_id:
cursor.execute( cursor.execute(
@ -1187,7 +1214,11 @@ async def create_sag(request: Request, data: dict):
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]), (result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
) )
from app.modules.sag.backend.create_support import attach_create_relations
tag_actions = attach_create_relations(cursor, result["id"], data, current_user_id)
conn.commit() conn.commit()
result = dict(result)
result["tag_actions"] = tag_actions
logger.info("✅ Case created: %s", result["id"]) logger.info("✅ Case created: %s", result["id"])
return dict(result) return dict(result)
except Exception: except Exception:
@ -1229,6 +1260,7 @@ async def link_sag_internet_connection(sag_id: int, request: Request, data: dict
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""", VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False, (sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
) )
_ensure_case_internet_connection_tag(sag_id)
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id} return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
@ -2576,6 +2608,28 @@ async def list_case_contacts(sag_id: int):
logger.error("❌ Error listing case contacts: %s", e) logger.error("❌ Error listing case contacts: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case contacts") raise HTTPException(status_code=500, detail="Failed to list case contacts")
@router.get("/sag/{sag_id}/reply-recipients")
async def list_case_reply_recipients(sag_id: int, q: str = ""):
"""Email-capable case contacts first, followed by contacts from the case customer."""
rows = execute_query(
"""
WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS (
SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
WHERE sk.sag_id=%s AND sk.deleted_at IS NULL
UNION
SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id
) SELECT DISTINCT ON (id) * FROM candidates
WHERE NULLIF(TRIM(email),'') IS NOT NULL
AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%')
ORDER BY id, linked DESC
""",
(sag_id, sag_id, q.strip(), q.strip()),
) or []
return rows
@router.post("/sag/{sag_id}/contacts") @router.post("/sag/{sag_id}/contacts")
async def add_case_contact(sag_id: int, data: dict): async def add_case_contact(sag_id: int, data: dict):
"""Add a contact to a case.""" """Add a contact to a case."""
@ -3243,6 +3297,44 @@ async def list_sale_items(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list sale items") raise HTTPException(status_code=500, detail="Failed to list sale items")
@router.get("/procurement/overview")
async def procurement_overview():
"""Operational queue for purchase lines awaiting order, receipt or delivery."""
rows = execute_query(
"""
SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount,
p.status, p.line_date, p.external_ref, p.purchase_purpose,
p.supplier_invoice_id, p.supplier_invoice_line_id,
s.titel AS case_title, c.name AS customer_name,
EXISTS (
SELECT 1 FROM sag_salgsvarer sale
WHERE sale.sag_id = p.sag_id AND sale.type = 'sale'
AND sale.status <> 'cancelled'
) AS has_sales_line,
CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received'
WHEN p.status = 'confirmed' THEN 'ordered'
ELSE 'to_order'
END AS fulfilment_state
FROM sag_salgsvarer p
JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL
LEFT JOIN customers c ON c.id = s.customer_id
WHERE p.type = 'purchase' AND p.status <> 'cancelled'
ORDER BY CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2
WHEN p.status = 'confirmed' THEN 1 ELSE 0 END,
p.line_date NULLS LAST, p.id DESC
"""
) or []
counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0}
for row in rows:
state = row.get("fulfilment_state") or "to_order"
counts[state] = counts.get(state, 0) + 1
if not row.get("has_sales_line"):
counts["missing_sales_order"] += 1
return {"items": rows, "counts": counts}
@router.post("/sag/{sag_id}/sale-items") @router.post("/sag/{sag_id}/sale-items")
async def create_sale_item(sag_id: int, data: dict): async def create_sale_item(sag_id: int, data: dict):
"""Create a sale item for a case.""" """Create a sale item for a case."""

View File

@ -25,6 +25,15 @@ async def solutions_management(request: Request):
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request}) return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
@router.get("/procurement", response_class=HTMLResponse)
async def procurement_overview_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/procurement_overview.html", {"request": request})
@router.get("/reminder-rules", response_class=HTMLResponse)
async def reminder_rules_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/reminder_rules.html", {"request": request})
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse) @router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int): async def knowledge_detail(request: Request, article_id: int):
article = execute_query( article = execute_query(

View File

@ -5119,6 +5119,15 @@
const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }}; const caseTypeKey = {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
const initialCaseTagsSnapshot = {{ (tags or [])|tojson }}; const initialCaseTagsSnapshot = {{ (tags or [])|tojson }};
const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }}; const initialCaseBuzzwordsSnapshot = {{ (buzzwords or [])|tojson }};
let caseHasInternetConnectionLink = false;
function caseHasInternetConnectionContext() {
if (caseHasInternetConnectionLink) return true;
return initialCaseTagsSnapshot.some((tag) => {
const value = String(tag?.tag_navn || tag?.name || '').trim().toLocaleLowerCase('da-DK');
return value === 'internet forbindelse';
});
}
async function markCaseAsRecentlyOpened() { async function markCaseAsRecentlyOpened() {
try { try {
@ -5211,6 +5220,7 @@
'emails': 'E-mails', 'emails': 'E-mails',
'pipeline': 'Salgspipeline', 'pipeline': 'Salgspipeline',
'hardware': 'Hardware', 'hardware': 'Hardware',
'internet-connections': 'Internetforbindelser',
'locations': 'Lokationer', 'locations': 'Lokationer',
'contacts': 'Kontakter', 'contacts': 'Kontakter',
'customers': 'Kunder', 'customers': 'Kunder',
@ -5274,6 +5284,7 @@
// Load Hardware & Locations // Load Hardware & Locations
loadCaseHardware(); loadCaseHardware();
loadCaseLocations(); loadCaseLocations();
loadCaseInternetConnections();
loadCaseWiki(); loadCaseWiki();
loadTodoSteps(); loadTodoSteps();
loadCaseTagsModule(); loadCaseTagsModule();
@ -5308,6 +5319,18 @@
}); });
} }
const internetConnectionSearch = document.getElementById('case-internet-connection-search');
if (internetConnectionSearch) {
let timer;
internetConnectionSearch.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(searchCaseInternetConnections, 250);
});
internetConnectionSearch.addEventListener('keydown', (event) => {
if (event.key === 'Escape') toggleCaseInternetConnectionPicker(false);
});
}
['topbarStatusSelect', 'tabsAssignmentUserSelect', 'tabsAssignmentGroupSelect', 'topbarTypeSelect', 'topbarPrioritySelect', 'topbarStartDateInput', 'topbarDeferredInput', 'topbarDeadlineInput'].forEach((id) => { ['topbarStatusSelect', 'tabsAssignmentUserSelect', 'tabsAssignmentGroupSelect', 'topbarTypeSelect', 'topbarPrioritySelect', 'topbarStartDateInput', 'topbarDeferredInput', 'topbarDeadlineInput'].forEach((id) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) { if (el) {
@ -6200,6 +6223,110 @@
return div.innerHTML; return div.innerHTML;
} }
function toggleCaseInternetConnectionPicker(forceOpen) {
const picker = document.getElementById('case-internet-connection-picker');
const input = document.getElementById('case-internet-connection-search');
if (!picker) return;
const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : picker.classList.contains('d-none');
picker.classList.toggle('d-none', !shouldOpen);
if (shouldOpen) setTimeout(() => input?.focus(), 0);
}
async function loadCaseInternetConnections() {
const container = document.getElementById('case-internet-connections-list');
if (!container) return;
try {
const response = await fetch(`/api/v1/sag/${caseId}/internet-connections`, { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente internetforbindelser');
const items = await response.json();
if (items.length && !caseHasInternetConnectionLink) {
caseHasInternetConnectionLink = true;
applyViewLayout(currentCaseView);
}
const card = container.closest('.right-module-card');
if (card) card.dataset.hasContent = items.length ? 'true' : 'false';
if (!items.length) {
container.innerHTML = '<div class="text-center text-muted small py-2">Ingen internetforbindelser tilknyttet</div>';
return;
}
container.innerHTML = items.map((item) => {
const name = item.name || item.circuit_number || item.provider_reference || `Forbindelse #${item.connection_id}`;
const meta = [item.circuit_number, item.provider_reference, item.address].filter(Boolean).map(escapeHtml).join(' · ');
return `<div class="d-flex align-items-center gap-2 py-2 border-bottom">
<i class="bi bi-router text-primary"></i>
<a class="flex-grow-1 text-decoration-none text-body" href="/economy/internet-connections/${item.connection_id}">
<div class="fw-semibold small">${escapeHtml(name)}</div>
${meta ? `<div class="small text-muted text-truncate">${meta}</div>` : ''}
</a>
<button class="btn btn-sm btn-outline-danger border-0" type="button" onclick="unlinkCaseInternetConnection(${item.connection_id})" title="Fjern fra sag" aria-label="Fjern fra sag"><i class="bi bi-x-lg"></i></button>
</div>`;
}).join('');
} catch (error) {
console.error(error);
container.innerHTML = '<div class="text-danger small py-2">Kunne ikke hente forbindelser</div>';
}
}
async function searchCaseInternetConnections() {
const input = document.getElementById('case-internet-connection-search');
const results = document.getElementById('case-internet-connection-results');
if (!input || !results) return;
const query = input.value.trim();
if (query.length < 2) {
results.classList.add('d-none');
results.innerHTML = '';
return;
}
results.classList.remove('d-none');
results.innerHTML = '<div class="list-group-item text-muted">Søger …</div>';
try {
const response = await fetch(`/api/v1/internet-connections?q=${encodeURIComponent(query)}`, { credentials: 'include' });
if (!response.ok) throw new Error('Søgning fejlede');
const items = (await response.json()).slice(0, 8);
if (!items.length) {
results.innerHTML = '<div class="list-group-item text-muted">Ingen forbindelser fundet</div>';
return;
}
results.innerHTML = items.map((item) => {
const name = item.name || item.circuit_number || item.provider_reference || `Forbindelse #${item.id}`;
const meta = [item.customer_name, item.circuit_number, item.address].filter(Boolean).map(escapeHtml).join(' · ');
return `<button type="button" class="list-group-item list-group-item-action" onclick="linkCaseInternetConnection(${item.id})">
<div class="fw-semibold">${escapeHtml(name)}</div><div class="small text-muted">${meta || 'Ingen yderligere oplysninger'}</div>
</button>`;
}).join('');
} catch (error) {
results.innerHTML = '<div class="list-group-item text-danger">Kunne ikke søge efter forbindelser</div>';
}
}
async function linkCaseInternetConnection(connectionId) {
try {
const response = await fetch(`/api/v1/sag/${caseId}/internet-connections`, {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({connection_id: connectionId})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Kunne ikke tilknytte forbindelsen');
}
document.getElementById('case-internet-connection-search').value = '';
document.getElementById('case-internet-connection-results').classList.add('d-none');
toggleCaseInternetConnectionPicker(false);
caseHasInternetConnectionLink = true;
await loadCaseInternetConnections();
applyViewLayout(currentCaseView);
showCaseFeedback('Internetforbindelsen er tilknyttet sagen');
} catch (error) { showCaseFeedback(error.message || 'Kunne ikke tilknytte forbindelsen'); }
}
async function unlinkCaseInternetConnection(connectionId) {
try {
const response = await fetch(`/api/v1/sag/${caseId}/internet-connections/${connectionId}`, {method: 'DELETE', credentials: 'include'});
if (!response.ok) throw new Error('Kunne ikke fjerne forbindelsen');
await loadCaseInternetConnections();
} catch (error) { showCaseFeedback(error.message || 'Kunne ikke fjerne forbindelsen'); }
}
function sanitizeCaseEmailHtml(unsafeHtml) { function sanitizeCaseEmailHtml(unsafeHtml) {
const input = String(unsafeHtml || '').trim(); const input = String(unsafeHtml || '').trim();
if (!input) return ''; if (!input) return '';
@ -7671,6 +7798,25 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card module-priority-normal" data-module="internet-connections" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-router-fill module-icon"></i>Internetforbindelser</h6>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleCaseInternetConnectionPicker()" title="Tilknyt internetforbindelse" aria-label="Tilknyt internetforbindelse">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 235px;">
<div id="case-internet-connection-picker" class="d-none mb-2">
<div class="input-group input-group-sm">
<input id="case-internet-connection-search" type="search" class="form-control" autocomplete="off" placeholder="Søg navn, kredsløb, adresse …" aria-label="Søg internetforbindelse">
<button class="btn btn-primary" type="button" onclick="searchCaseInternetConnections()"><i class="bi bi-search"></i></button>
</div>
<div id="case-internet-connection-results" class="list-group mt-1 small d-none"></div>
</div>
<div id="case-internet-connections-list"><div class="text-center text-muted small py-2">Henter forbindelser …</div></div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="{{ 'true' if tags and tags|length > 0 else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card module-priority-low" data-module="tags" data-has-content="{{ 'true' if tags and tags|length > 0 else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6> <h6 class="module-title"><i class="bi bi-tags-fill module-icon"></i>TAGS</h6>
@ -10473,6 +10619,32 @@
} }
} }
let selectedCommentReplyRecipients = [];
let replyRecipientModal = null;
async function openCommentReplyRecipients() {
const list = document.getElementById('commentReplyRecipientList');
if (!list) return;
list.innerHTML = '<div class="text-muted small">Henter kontaktpersoner…</div>';
replyRecipientModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('commentReplyRecipientModal'));
replyRecipientModal.show();
const query = document.getElementById('commentReplyRecipientSearch')?.value || '';
const res = await fetch(`/api/v1/sag/${caseIds}/reply-recipients?q=${encodeURIComponent(query)}`);
const contacts = res.ok ? await res.json() : [];
list.innerHTML = contacts.length ? contacts.map(contact => `<label class="d-flex align-items-center gap-2 border rounded-3 px-3 py-2 mb-1 small bg-white comment-reply-recipient-row"><input class="form-check-input comment-reply-recipient" type="checkbox" value="${escapeHtml(contact.email)}" ${contact.linked ? 'checked' : ''}><span><strong class="fw-semibold">${escapeHtml([contact.first_name,contact.last_name].filter(Boolean).join(' ') || contact.email)}</strong><span class="d-block text-muted" style="font-size:.78rem">${escapeHtml(contact.email)}${contact.linked ? ' · Koblet på sagen' : ''}</span></span></label>`).join('') : '<div class="text-muted small">Ingen kontaktpersoner med e-mail fundet.</div>';
if (!selectedCommentReplyRecipients.length) selectedCommentReplyRecipients = contacts.filter(c => c.linked).map(c => c.email);
list.querySelectorAll('.comment-reply-recipient').forEach(box => box.checked = selectedCommentReplyRecipients.includes(box.value));
}
function confirmCommentReplyRecipients() {
const manual = document.getElementById('commentReplyManualEmail')?.value.trim();
const chosen = [...document.querySelectorAll('.comment-reply-recipient:checked')].map(item => item.value);
if (manual) chosen.push(...manual.split(/[;,\s]+/).filter(value => value.includes('@')));
selectedCommentReplyRecipients = [...new Set(chosen)];
if (!selectedCommentReplyRecipients.length) return showCaseFeedback('Vælg mindst én modtager eller skriv en e-mailadresse.');
document.getElementById('commentActionReplyCustomer').checked = true;
document.getElementById('commentActionReplyCustomerLabel').title = `Svar kunden (${selectedCommentReplyRecipients.join(', ')})`;
replyRecipientModal?.hide();
}
function clearSelectedCommentActions() { function clearSelectedCommentActions() {
[ [
'commentActionSolution', 'commentActionSolution',
@ -10599,8 +10771,8 @@
} }
async function sendCustomerReplyFromComment(content) { async function sendCustomerReplyFromComment(content) {
const recipient = String((typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : '') || '').trim(); const recipients = selectedCommentReplyRecipients.length ? selectedCommentReplyRecipients : [String((typeof getDefaultCaseRecipient === 'function' ? getDefaultCaseRecipient() : '') || '').trim()].filter(Boolean);
if (!recipient) { if (!recipients.length) {
throw new Error('Ingen standard modtager fundet til Svar kunden'); throw new Error('Ingen standard modtager fundet til Svar kunden');
} }
@ -10611,7 +10783,7 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
to: [recipient], to: recipients,
cc: [], cc: [],
bcc: [], bcc: [],
subject, subject,
@ -10836,6 +11008,10 @@
} }
if (replyCheckbox) { if (replyCheckbox) {
replyCheckbox.addEventListener('mouseenter', refreshReplyCustomerHoverText); replyCheckbox.addEventListener('mouseenter', refreshReplyCustomerHoverText);
replyCheckbox.addEventListener('change', () => {
if (replyCheckbox.checked) openCommentReplyRecipients();
else selectedCommentReplyRecipients = [];
});
} }
if (timeCheckbox) { if (timeCheckbox) {
timeCheckbox.addEventListener('change', toggleCommentQuickTimeRow); timeCheckbox.addEventListener('change', toggleCommentQuickTimeRow);
@ -11364,6 +11540,10 @@
document.getElementById('sale_purchase_purpose').value = item?.purchase_purpose || ''; document.getElementById('sale_purchase_purpose').value = item?.purchase_purpose || '';
document.getElementById('sale_supplier_invoice_id').value = item?.supplier_invoice_id || ''; document.getElementById('sale_supplier_invoice_id').value = item?.supplier_invoice_id || '';
document.getElementById('sale_supplier_invoice_line_id').value = item?.supplier_invoice_line_id || ''; document.getElementById('sale_supplier_invoice_line_id').value = item?.supplier_invoice_line_id || '';
document.getElementById('sale_product_id').value = item?.product_id || '';
document.getElementById('sale_product_search').value = item?.product_name || '';
document.getElementById('sale_product_results').innerHTML = '';
document.getElementById('sale_product_hint').textContent = item?.product_id ? 'Katalogvare valgt' : 'Ingen katalogvare valgt — linjen gemmes som en midlertidig vare.';
togglePurchaseFields(); togglePurchaseFields();
@ -11398,6 +11578,43 @@
} }
} }
let saleProductSearchTimer = null;
async function searchSaleProducts() {
const query = document.getElementById('sale_product_search')?.value.trim() || '';
const results = document.getElementById('sale_product_results');
if (!results) return;
if (query.length < 2) { results.innerHTML = ''; return; }
try {
const res = await fetch(`/api/v1/products?q=${encodeURIComponent(query)}&status=active`);
const products = res.ok ? await res.json() : [];
results.innerHTML = products.slice(0, 8).map(product => `<button type="button" class="list-group-item list-group-item-action" onclick="selectSaleProduct(${product.id})"><strong>${escapeHtml(product.name || 'Produkt')}</strong><span class="d-block small text-muted">${escapeHtml([product.sku_internal && 'SKU '+product.sku_internal, product.ean && 'EAN '+product.ean, product.sales_price != null && formatCurrency(product.sales_price)].filter(Boolean).join(' · ') || 'Ingen standardpris')}</span></button>`).join('') || '<div class="small text-muted px-2 py-2">Ingen katalogvarer fundet — du kan fortsætte som midlertidig vare.</div>';
window.saleProductSearchResults = products;
} catch (_) { results.innerHTML = '<div class="small text-muted px-2 py-2">Søgning er midlertidigt utilgængelig.</div>'; }
}
function queueSaleProductSearch() { clearTimeout(saleProductSearchTimer); saleProductSearchTimer = setTimeout(searchSaleProducts, 180); }
function selectSaleProduct(productId) {
const product = (window.saleProductSearchResults || []).find(item => Number(item.id) === Number(productId));
if (!product) return;
document.getElementById('sale_product_id').value = product.id;
document.getElementById('sale_product_search').value = product.name || '';
document.getElementById('sale_description').value = product.short_description || product.name || '';
if (product.sales_price != null) document.getElementById('sale_unit_price').value = Number(product.sales_price).toFixed(2);
if (!document.getElementById('sale_quantity').value) document.getElementById('sale_quantity').value = '1';
document.getElementById('sale_product_hint').textContent = 'Katalogvare valgt — beskrivelse og standardpris er sat.';
document.getElementById('sale_product_results').innerHTML = '';
updateSaleAmount();
}
function useTemporarySaleItem() {
document.getElementById('sale_product_id').value = '';
document.getElementById('sale_product_search').value = '';
document.getElementById('sale_product_results').innerHTML = '';
document.getElementById('sale_product_hint').textContent = 'Midlertidig vare — varenummer og pris kan tilføjes senere.';
document.getElementById('sale_description').focus();
}
async function saveSaleItem() { async function saveSaleItem() {
const itemId = document.getElementById('sale_item_id').value; const itemId = document.getElementById('sale_item_id').value;
const payload = { const payload = {
@ -11408,12 +11625,13 @@
quantity: document.getElementById('sale_quantity').value || null, quantity: document.getElementById('sale_quantity').value || null,
unit: document.getElementById('sale_unit').value || null, unit: document.getElementById('sale_unit').value || null,
unit_price: document.getElementById('sale_unit_price').value || null, unit_price: document.getElementById('sale_unit_price').value || null,
amount: document.getElementById('sale_amount').value, amount: document.getElementById('sale_amount').value || (document.getElementById('sale_status').value === 'draft' ? '0' : null),
currency: document.getElementById('sale_currency').value || 'DKK', currency: document.getElementById('sale_currency').value || 'DKK',
external_ref: document.getElementById('sale_external_ref').value || null, external_ref: document.getElementById('sale_external_ref').value || null,
purchase_purpose: document.getElementById('sale_purchase_purpose').value || null, purchase_purpose: document.getElementById('sale_purchase_purpose').value || null,
supplier_invoice_id: document.getElementById('sale_supplier_invoice_id').value || null, supplier_invoice_id: document.getElementById('sale_supplier_invoice_id').value || null,
supplier_invoice_line_id: document.getElementById('sale_supplier_invoice_line_id').value || null supplier_invoice_line_id: document.getElementById('sale_supplier_invoice_line_id').value || null,
product_id: document.getElementById('sale_product_id').value || null
}; };
if (payload.type !== 'purchase') { if (payload.type !== 'purchase') {
@ -11422,8 +11640,12 @@
payload.supplier_invoice_line_id = null; payload.supplier_invoice_line_id = null;
} }
if (!payload.description || !payload.amount) { if (!payload.description || payload.amount === null) {
showCaseFeedback('Beskrivelse og linjesum er påkrævet.'); showCaseFeedback(payload.status === 'draft' ? 'Beskrivelse er påkrævet.' : 'Beskrivelse og linjesum er påkrævet ved bekræftelse.');
return;
}
if (payload.status !== 'draft' && Number(payload.amount) <= 0) {
showCaseFeedback('En varelinje med ukendt pris skal blive som kladde, indtil pris er angivet.');
return; return;
} }
@ -11443,7 +11665,47 @@
return; return;
} }
const savedItem = await res.json();
bootstrap.Modal.getInstance(document.getElementById('saleItemModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('saleItemModal')).hide();
if (!itemId && payload.type === 'purchase') {
openPurchaseForwardModal(savedItem, payload);
} else {
await loadVarekobSalg();
}
}
let pendingPurchaseForward = null;
function openPurchaseForwardModal(purchase, payload) {
pendingPurchaseForward = {purchase, payload};
document.getElementById('purchaseForwardDescription').value = payload.description || '';
document.getElementById('purchaseForwardQuantity').value = payload.quantity || 1;
document.getElementById('purchaseForwardUnit').value = payload.unit || 'stk';
document.getElementById('purchaseForwardPrice').value = '';
document.getElementById('purchaseForwardCreateSale').checked = true;
new bootstrap.Modal(document.getElementById('purchaseForwardModal')).show();
}
async function completePurchaseForwarding() {
const shouldCreate = document.getElementById('purchaseForwardCreateSale').checked;
const modal = bootstrap.Modal.getInstance(document.getElementById('purchaseForwardModal'));
if (!shouldCreate || !pendingPurchaseForward) {
modal?.hide(); await loadVarekobSalg(); return;
}
const quantity = Number(document.getElementById('purchaseForwardQuantity').value || 1);
const unitPrice = Number(document.getElementById('purchaseForwardPrice').value || 0);
const payload = {
type: 'sale', status: 'draft', line_date: new Date().toISOString().slice(0,10),
description: document.getElementById('purchaseForwardDescription').value.trim(),
quantity, unit: document.getElementById('purchaseForwardUnit').value.trim() || 'stk',
unit_price: unitPrice, amount: (quantity * unitPrice).toFixed(2), currency: 'DKK',
external_ref: `Købslinje #${pendingPurchaseForward.purchase.id}`,
product_id: pendingPurchaseForward.purchase.product_id || null,
};
if (!payload.description) return showCaseFeedback('Angiv en beskrivelse til salgslinjen.');
const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
if (!res.ok) return showCaseFeedback('Kunne ikke oprette salgskladde.');
modal?.hide(); pendingPurchaseForward = null;
showCaseFeedback('Salgskladde er oprettet fra købslinjen.');
await loadVarekobSalg(); await loadVarekobSalg();
} }
@ -13948,15 +14210,22 @@
<!-- Modal for Sale Item --> <!-- Modal for Sale Item -->
<div class="modal fade" id="saleItemModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="saleItemModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content border-0 shadow rounded-4 overflow-hidden">
<div class="modal-header"> <div class="modal-header border-bottom px-4 py-3">
<h5 class="modal-title"><i class="bi bi-basket3"></i> Varelinje</h5> <div class="d-flex align-items-center gap-3"><span class="d-inline-flex align-items-center justify-content-center rounded-3 text-primary" style="width:42px;height:42px;background:#eaf4ff"><i class="bi bi-bag-plus fs-5"></i></span><div><h5 class="modal-title mb-0 fw-bold">Tilføj varelinje</h5><div class="small text-muted">Tilføj et produkt eller gem en kladde til senere.</div></div></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body p-4">
<form id="saleItemForm"> <form id="saleItemForm">
<input type="hidden" id="sale_item_id"> <input type="hidden" id="sale_item_id">
<input type="hidden" id="sale_product_id">
<div class="border rounded-3 p-3 mb-4 bg-light">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2"><label class="form-label fw-semibold mb-0" for="sale_product_search"><i class="bi bi-search me-1 text-primary"></i>Produkt</label><button type="button" class="btn btn-sm btn-link text-decoration-none px-0" onclick="useTemporarySaleItem()">Fortsæt uden katalogvare</button></div>
<div class="input-group"><span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span><input type="search" class="form-control border-start-0 ps-0" id="sale_product_search" oninput="queueSaleProductSearch()" placeholder="Søg navn, SKU eller EAN" autocomplete="off"></div>
<div class="list-group mt-2" id="sale_product_results"></div>
<div class="small text-muted mt-2" id="sale_product_hint">Valgfrit — uden valg oprettes en midlertidig varelinje.</div>
</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Type *</label> <label class="form-label">Type *</label>
@ -14040,6 +14309,21 @@
</div> </div>
</div> </div>
<div class="modal fade" id="purchaseForwardModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow rounded-4">
<div class="modal-header border-bottom"><div><h5 class="modal-title fw-bold"><i class="bi bi-arrow-left-right text-primary me-2"></i>Skal købet viderefaktureres?</h5><div class="small text-muted">Købslinjen er gemt. Vælg nu, om der også skal oprettes en salgslinje.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="form-check form-switch mb-3"><input class="form-check-input" type="checkbox" role="switch" id="purchaseForwardCreateSale" checked><label class="form-check-label fw-semibold" for="purchaseForwardCreateSale">Opret salgskladde på sagen</label></div>
<div class="rounded-3 bg-light border p-3"><div class="mb-2"><label class="form-label small fw-semibold">Beskrivelse</label><input class="form-control" id="purchaseForwardDescription"></div><div class="row g-2"><div class="col-4"><label class="form-label small fw-semibold">Antal</label><input type="number" class="form-control" id="purchaseForwardQuantity" min="0.01" step="0.01"></div><div class="col-4"><label class="form-label small fw-semibold">Enhed</label><input class="form-control" id="purchaseForwardUnit"></div><div class="col-4"><label class="form-label small fw-semibold">Kundepris</label><input type="number" class="form-control" id="purchaseForwardPrice" min="0" step="0.01" placeholder="Kan sættes senere"></div></div><div class="small text-muted mt-2">Uden kundepris oprettes en salgskladde til 0 kr., som skal færdiggøres før fakturering.</div></div>
</div>
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal" onclick="loadVarekobSalg()">Ikke nu</button><button class="btn btn-primary" onclick="completePurchaseForwarding()"><i class="bi bi-receipt me-1"></i>Gem valg</button></div>
</div>
</div>
</div>
<div class="modal fade" id="commentReplyRecipientModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-dialog-scrollable modal-md"><div class="modal-content border-0 shadow rounded-4 overflow-hidden"><div class="modal-header px-4 py-3 border-bottom"><div><div class="text-uppercase text-primary fw-bold" style="font-size:.67rem;letter-spacing:.07em">Svar kunden</div><h6 class="modal-title mb-1 fw-bold">Vælg modtagere</h6><div class="text-muted" style="font-size:.78rem">Kontaktpersoner på sagen er valgt som udgangspunkt.</div></div><button class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body p-3"><div class="input-group input-group-sm mb-3"><span class="input-group-text bg-white"><i class="bi bi-search"></i></span><input class="form-control" id="commentReplyRecipientSearch" placeholder="Søg i virksomhedens kontakter" oninput="openCommentReplyRecipients()"></div><div id="commentReplyRecipientList" class="pb-1"></div><div class="border-top mt-3 pt-3"><label class="form-label small fw-semibold mb-1">Tilføj e-mail manuelt</label><input class="form-control form-control-sm" id="commentReplyManualEmail" placeholder="navn@firma.dk — flere med komma"></div></div><div class="modal-footer px-3 py-2"><button class="btn btn-sm btn-light border" data-bs-dismiss="modal">Annuller</button><button class="btn btn-sm btn-primary" onclick="confirmCommentReplyRecipients()">Brug valgte</button></div></div></div></div>
<!-- Modal for Internal Time --> <!-- Modal for Internal Time -->
<div class="modal fade" id="createTimeModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="createTimeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@ -16382,6 +16666,7 @@
const isTimeModule = moduleName === 'time'; const isTimeModule = moduleName === 'time';
const isBuzzwordsModule = moduleName === 'buzzwords'; const isBuzzwordsModule = moduleName === 'buzzwords';
const isShippingModule = moduleName === 'shipping'; const isShippingModule = moduleName === 'shipping';
const isInternetConnectionModule = moduleName === 'internet-connections';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'tags' && moduleName !== 'buzzwords' && !isTimeModule; const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && moduleName !== 'tags' && moduleName !== 'buzzwords' && !isTimeModule;
const pref = modulePrefs[moduleName]; const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
@ -16431,6 +16716,14 @@
return; return;
} }
// Only show this operational box for internet cases: either the
// explicit case tag is present, or a connection has been linked.
if (isInternetConnectionModule) {
setVisibility(caseHasInternetConnectionContext());
el.classList.remove('module-empty-compact');
return;
}
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content. // HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
if (pref === false) { if (pref === false) {
setVisibility(false); setVisibility(false);
@ -19604,6 +19897,7 @@
} }
if (subscription.status === 'active') { if (subscription.status === 'active') {
buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`); buttons.push(`<button class="btn btn-sm btn-warning" onclick="updateSubscriptionStatus('paused')"><i class="bi bi-pause-circle me-1"></i>Pause</button>`);
buttons.push(`<button class="btn btn-sm btn-outline-primary" onclick="processCurrentSubscriptionInvoice()"><i class="bi bi-receipt-cutoff me-1"></i>Fakturér nu</button>`);
} }
if (subscription.status !== 'cancelled') { if (subscription.status !== 'cancelled') {
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`); buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
@ -19642,6 +19936,7 @@
} }
let currentSubscriptionAgreement = null; let currentSubscriptionAgreement = null;
let agreementChangeMode = false;
function subscriptionScheduleLabel(subscription) { function subscriptionScheduleLabel(subscription) {
if (subscription.billing_schedule_type === 'first_business_day') return 'Første bankdag'; if (subscription.billing_schedule_type === 'first_business_day') return 'Første bankdag';
@ -19722,7 +20017,33 @@
function collectAgreementChangeLines(card) { function collectAgreementChangeLines(card) {
const allSubscriptions = [...(currentSubscriptionAgreement?.current_subscriptions || []), ...(currentSubscriptionAgreement?.upcoming_subscriptions || [])]; const allSubscriptions = [...(currentSubscriptionAgreement?.current_subscriptions || []), ...(currentSubscriptionAgreement?.upcoming_subscriptions || [])];
const originalLines = allSubscriptions.find(item => Number(item.id) === Number(card.dataset.subscriptionId))?.line_items || []; const originalLines = allSubscriptions.find(item => Number(item.id) === Number(card.dataset.subscriptionId))?.line_items || [];
return [...card.querySelectorAll('.sub-change-line')].map((row, index) => { const original = originalLines[index] || {}; return {product_id: row.dataset.productId ? Number(row.dataset.productId) : null, asset_id: row.dataset.assetId ? Number(row.dataset.assetId) : null, description: row.querySelector('.sub-change-line-description').value.trim(), quantity: Number(row.querySelector('.sub-change-line-quantity').value || 0), unit_price: Number(row.querySelector('.sub-change-line-price').value || 0), period_from: row.querySelector('.sub-change-line-from').value || null, period_to: row.querySelector('.sub-change-line-to').value || null, price_type: row.dataset.priceType || 'manual', custom_price_override: true, requires_serial_number: Boolean(original.requires_serial_number), serial_number: original.serial_number || null, billing_blocked: Boolean(original.billing_blocked), billing_block_reason: original.billing_block_reason || null}; }); const productInput = card.querySelector('.sub-change-product');
const chosenProduct = subscriptionProducts.find(product => product.name === productInput?.value.trim());
return [...card.querySelectorAll('.sub-change-line')].map((row, index) => {
const original = originalLines[index] || {};
const isPrimaryLine = index === 0 && chosenProduct;
return {
product_id: isPrimaryLine ? Number(chosenProduct.id) : (row.dataset.productId ? Number(row.dataset.productId) : null),
asset_id: row.dataset.assetId ? Number(row.dataset.assetId) : null,
description: isPrimaryLine ? (chosenProduct.short_description || chosenProduct.name) : row.querySelector('.sub-change-line-description').value.trim(),
quantity: Number(row.querySelector('.sub-change-line-quantity').value || 0),
unit_price: isPrimaryLine ? Number(chosenProduct.sales_price ?? (row.querySelector('.sub-change-line-price').value || 0)) : Number(row.querySelector('.sub-change-line-price').value || 0),
period_from: row.querySelector('.sub-change-line-from').value || null,
period_to: row.querySelector('.sub-change-line-to').value || null,
price_type: row.dataset.priceType || 'manual', custom_price_override: true,
requires_serial_number: Boolean(original.requires_serial_number), serial_number: original.serial_number || null,
billing_blocked: Boolean(original.billing_blocked), billing_block_reason: original.billing_block_reason || null,
};
});
}
function syncAgreementChangeMode() {
document.querySelectorAll('.subscription-agreement-card').forEach(card => {
const enabled = agreementChangeMode && Boolean(card.querySelector('.subscription-change-select')?.checked);
card.querySelectorAll('.sub-change-product, .sub-change-interval, .sub-change-schedule, .sub-change-day, .sub-change-status, .sub-change-line input, .sub-change-add-line, .sub-change-remove-line')
.forEach(control => control.disabled = !enabled);
card.classList.toggle('agreement-editing', enabled);
});
} }
function refreshAgreementChangePreview(card) { function refreshAgreementChangePreview(card) {
@ -19732,15 +20053,22 @@
const price = card.querySelector('.sub-change-price'); if (price) price.value = total.toFixed(2); const price = card.querySelector('.sub-change-price'); if (price) price.value = total.toFixed(2);
const headline = card.querySelector('.agreement-price'); if (headline) headline.textContent = formatSubscriptionCurrencyDetailed(total); const headline = card.querySelector('.agreement-price'); if (headline) headline.textContent = formatSubscriptionCurrencyDetailed(total);
const effective = document.getElementById('agreementEffectiveDate')?.value; const effective = document.getElementById('agreementEffectiveDate')?.value;
const subscription = agreementSubscriptionById(card.dataset.subscriptionId) || {};
const nextInvoiceDate = subscription.next_invoice_date || null;
const changeAffectsNextInvoice = Boolean(effective && nextInvoiceDate && effective <= nextInvoiceDate);
const nextInvoiceAmount = changeAffectsNextInvoice ? total : Number(subscription.price || 0);
const interval = card.querySelector('.sub-change-interval')?.value || 'monthly'; const interval = card.querySelector('.sub-change-interval')?.value || 'monthly';
let fraction = 1, transition = 'Hel periode'; let fraction = 1, transition = 'Hel periode';
if (effective && ['monthly','quarterly','yearly'].includes(interval) && Number(effective.slice(8,10)) > 1) { const days = Math.max(1, 31 - Math.min(Number(effective.slice(8,10)),30)); fraction = days / 30; transition = `${days}/30 dage`; } if (effective && ['monthly','quarterly','yearly'].includes(interval) && Number(effective.slice(8,10)) > 1) { const days = Math.max(1, 31 - Math.min(Number(effective.slice(8,10)),30)); fraction = days / 30; transition = `${days}/30 dage`; }
const target = card.querySelector('.sub-change-live-preview'); if (!target) return; const target = card.querySelector('.sub-change-live-preview'); if (!target) return;
target.innerHTML = `<div class="d-flex justify-content-between mb-2"><strong><i class="bi bi-lightning-charge-fill text-info me-1"></i>Live fakturavisning</strong><span class="badge bg-success-subtle text-success">Live</span></div><div class="change-live-preview-grid"><div><div class="agreement-fact-label">Ikrafttrædelse</div><div class="change-preview-value">${effective ? formatSubscriptionDate(effective) : 'Vælg dato'}</div></div><div><div class="agreement-fact-label">Skæv overgang</div><div class="change-preview-value">${transition}</div></div><div><div class="agreement-fact-label">Overgangsfaktura</div><div class="change-preview-value">${formatSubscriptionCurrencyDetailed(total * fraction)}</div></div><div><div class="agreement-fact-label">Løbende faktura</div><div class="change-preview-value">${formatSubscriptionCurrencyDetailed(total)}</div></div></div><div class="change-preview-lines">${lines.length ? lines.map(line => `<div class="d-flex justify-content-between gap-2"><span>${escapeHtml(line.description || 'Varelinje')} · ${line.quantity} × ${formatSubscriptionCurrencyDetailed(line.unit_price)}</span><strong>${formatSubscriptionCurrencyDetailed(line.quantity * line.unit_price)}</strong></div>`).join('') : '<span class="text-danger">Tilføj mindst én varelinje.</span>'}</div><div class="small text-muted mt-2"><i class="bi bi-calculator me-1"></i>Skæv periode vises efter 30-dagesreglen.</div>`; const nextLabel = nextInvoiceDate ? formatSubscriptionDate(nextInvoiceDate) : 'Ikke planlagt';
const impactLabel = !effective ? 'Vælg ikrafttrædelse for at se påvirkningen' : changeAffectsNextInvoice ? 'Ændringen er med på næste faktura' : 'Ændringen træder først i kraft efter næste faktura';
target.innerHTML = `<div class="d-flex justify-content-between mb-2"><strong><i class="bi bi-lightning-charge-fill text-info me-1"></i>Live fakturavisning</strong><span class="badge bg-success-subtle text-success">Live</span></div><div class="change-live-preview-grid"><div><div class="agreement-fact-label">Ikrafttrædelse</div><div class="change-preview-value">${effective ? formatSubscriptionDate(effective) : 'Vælg dato'}</div></div><div><div class="agreement-fact-label">Næste faktura</div><div class="change-preview-value">${nextLabel}</div></div><div><div class="agreement-fact-label">Næste fakturabeløb</div><div class="change-preview-value">${formatSubscriptionCurrencyDetailed(nextInvoiceAmount)}</div></div><div><div class="agreement-fact-label">Løbende faktura</div><div class="change-preview-value">${formatSubscriptionCurrencyDetailed(total)}</div></div></div><div class="small ${changeAffectsNextInvoice ? 'text-success' : 'text-muted'} mt-2"><i class="bi ${changeAffectsNextInvoice ? 'bi-check-circle' : 'bi-info-circle'} me-1"></i>${impactLabel}</div><div class="change-preview-lines">${lines.length ? lines.map(line => `<div class="d-flex justify-content-between gap-2"><span>${escapeHtml(line.description || 'Varelinje')} · ${line.quantity} × ${formatSubscriptionCurrencyDetailed(line.unit_price)}</span><strong>${formatSubscriptionCurrencyDetailed(line.quantity * line.unit_price)}</strong></div>`).join('') : '<span class="text-danger">Tilføj mindst én varelinje.</span>'}</div><div class="small text-muted mt-2"><i class="bi bi-calculator me-1"></i>Overgangsbeløb: ${formatSubscriptionCurrencyDetailed(total * fraction)} · ${transition}.</div>`;
} }
function renderSubscriptionAgreement(payload) { function renderSubscriptionAgreement(payload) {
currentSubscriptionAgreement = payload; currentSubscriptionAgreement = payload;
agreementChangeMode = false;
document.getElementById('subscriptionEmpty')?.classList.add('d-none'); document.getElementById('subscriptionEmpty')?.classList.add('d-none');
document.getElementById('subscriptionDetails')?.classList.add('d-none'); document.getElementById('subscriptionDetails')?.classList.add('d-none');
document.getElementById('subscriptionCreateForm')?.classList.add('d-none'); document.getElementById('subscriptionCreateForm')?.classList.add('d-none');
@ -19766,16 +20094,17 @@
${changePanel} ${changePanel}
<div class="agreement-toolbar mb-3"><div class="row g-2 align-items-center"><div class="col-lg-6"><div class="input-group"><span class="input-group-text border-0 bg-transparent"><i class="bi bi-search text-muted"></i></span><input class="form-control agreement-search" id="agreementSubscriptionSearch" placeholder="Søg produkt, nummer, asset, serienummer eller note…"></div></div><div class="col-lg-6"><div class="d-flex flex-wrap gap-1 justify-content-lg-end"><button class="agreement-filter active" data-filter="all">Alle</button><button class="agreement-filter" data-filter="current">Aktuelle</button><button class="agreement-filter" data-filter="upcoming">Kommende</button><button class="agreement-filter" data-filter="blocked">Blokerede</button></div></div></div></div> <div class="agreement-toolbar mb-3"><div class="row g-2 align-items-center"><div class="col-lg-6"><div class="input-group"><span class="input-group-text border-0 bg-transparent"><i class="bi bi-search text-muted"></i></span><input class="form-control agreement-search" id="agreementSubscriptionSearch" placeholder="Søg produkt, nummer, asset, serienummer eller note…"></div></div><div class="col-lg-6"><div class="d-flex flex-wrap gap-1 justify-content-lg-end"><button class="agreement-filter active" data-filter="all">Alle</button><button class="agreement-filter" data-filter="current">Aktuelle</button><button class="agreement-filter" data-filter="upcoming">Kommende</button><button class="agreement-filter" data-filter="blocked">Blokerede</button></div></div></div></div>
<div class="card border-primary-subtle bg-primary-subtle mb-3 d-none" id="agreementChangeComposer"><div class="card-body"><div class="d-flex justify-content-between align-items-start mb-3"><div><h6 class="fw-bold mb-1"><i class="bi bi-stars me-2"></i>Klargør ændringspakke</h6><div class="small text-muted" id="agreementSelectionSummary">Vælg abonnementer i kortene nedenfor.</div></div><button class="btn-close" onclick="document.getElementById('agreementChangeComposer').classList.add('d-none')"></button></div><div class="row g-3"><div class="col-md-7"><label class="form-label">Begrundelse *</label><textarea class="form-control" id="agreementChangeReason" rows="2" placeholder="Hvad ændres — og hvorfor?"></textarea></div><div class="col-md-3"><label class="form-label">Ikrafttrædelse *</label><input type="date" class="form-control" id="agreementEffectiveDate"></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100" onclick="saveSelectedSubscriptionChange()"><i class="bi bi-arrow-right-circle me-1"></i>Fortsæt</button></div></div></div></div> <div class="card border-primary-subtle bg-primary-subtle mb-3 d-none" id="agreementChangeComposer"><div class="card-body"><div class="d-flex justify-content-between align-items-start mb-3"><div><h6 class="fw-bold mb-1"><i class="bi bi-stars me-2"></i>Klargør ændringspakke</h6><div class="small text-muted" id="agreementSelectionSummary">Vælg abonnementer i kortene nedenfor.</div></div><button class="btn-close" onclick="document.getElementById('agreementChangeComposer').classList.add('d-none')"></button></div><div class="row g-3"><div class="col-md-7"><label class="form-label">Begrundelse *</label><textarea class="form-control" id="agreementChangeReason" rows="2" placeholder="Hvad ændres — og hvorfor?"></textarea></div><div class="col-md-3"><label class="form-label">Ikrafttrædelse *</label><input type="date" class="form-control" id="agreementEffectiveDate"></div><div class="col-md-2 d-flex align-items-end"><button class="btn btn-primary w-100" onclick="saveSelectedSubscriptionChange()"><i class="bi bi-arrow-right-circle me-1"></i>Fortsæt</button></div></div></div></div>
<datalist id="subscriptionProductOptions">${subscriptionProducts.map(product => `<option value="${escapeHtml(product.name || '')}">${escapeHtml([product.sku_internal, product.short_description].filter(Boolean).join(' · '))}</option>`).join('')}</datalist>
<div class="row g-3">${subscriptions.map(subscription => ` <div class="row g-3">${subscriptions.map(subscription => `
<div class="col-12 agreement-card-wrap" data-state="${subscription.status === 'scheduled' || (subscription.start_date && new Date(subscription.start_date) > new Date()) ? 'upcoming' : 'current'}" data-blocked="${subscription.billing_blocked || subscription.status === 'blocked'}" data-search="${escapeHtml(agreementSearchText(subscription))}"><div class="card agreement-card subscription-agreement-card" data-subscription-id="${subscription.id}"><div class="agreement-card-accent"></div> <div class="col-12 agreement-card-wrap" data-state="${subscription.status === 'scheduled' || (subscription.start_date && new Date(subscription.start_date) > new Date()) ? 'upcoming' : 'current'}" data-blocked="${subscription.billing_blocked || subscription.status === 'blocked'}" data-search="${escapeHtml(agreementSearchText(subscription))}"><div class="card agreement-card subscription-agreement-card" data-subscription-id="${subscription.id}"><div class="agreement-card-accent"></div>
<div class="card-header bg-white border-0 pt-3 d-flex justify-content-between align-items-start"> <div class="card-header bg-white border-0 pt-3 d-flex justify-content-between align-items-start">
<label class="d-flex align-items-center gap-3 mb-0"><input class="form-check-input subscription-change-select mt-0" type="checkbox" value="${subscription.id}"> <label class="d-flex align-items-center gap-3 mb-0"><input class="form-check-input subscription-change-select mt-0" type="checkbox" value="${subscription.id}">
<span><span class="agreement-number d-block">${subscription.subscription_number || '#' + subscription.id}</span><span class="agreement-name">${escapeHtml(subscription.product_name || 'Abonnement')}</span></span></label> <span><span class="agreement-number d-block">${subscription.subscription_number || '#' + subscription.id}</span><span class="agreement-name">${escapeHtml(subscription.product_name || 'Abonnement')}</span></span></label>
<div class="text-end"><span class="badge rounded-pill ${subscription.status === 'active' ? 'bg-success-subtle text-success' : subscription.status === 'blocked' ? 'bg-danger-subtle text-danger' : 'bg-secondary-subtle text-secondary'}">${subscriptionLifecycleLabel(subscription.status)}</span><div class="agreement-price mt-2">${formatSubscriptionCurrency(subscription.price)}</div></div> <div class="text-end"><span class="badge rounded-pill ${subscription.status === 'active' ? 'bg-success-subtle text-success' : subscription.status === 'blocked' ? 'bg-danger-subtle text-danger' : 'bg-secondary-subtle text-secondary'}">${subscriptionLifecycleLabel(subscription.status)}</span><div class="agreement-price mt-2">${formatSubscriptionCurrency(subscription.price)}</div>${subscription.status === 'draft' ? `<div class="d-flex justify-content-end gap-2 mt-2"><button type="button" class="btn btn-sm btn-outline-primary" onclick="editDraftFromAgreement(${subscription.id})"><i class="bi bi-pencil me-1"></i>Rediger kladde</button><button type="button" class="btn btn-sm btn-success" onclick="activateDraftFromAgreement(${subscription.id})"><i class="bi bi-play-fill me-1"></i>Aktivér</button></div>` : subscription.status === 'active' ? `<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="processSubscriptionFromAgreement(${subscription.id})"><i class="bi bi-receipt-cutoff me-1"></i>Fakturér nu</button>` : ''}</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-2"> <div class="row g-2">
<div class="col-md-3"><label class="small text-muted">Produkt</label><input class="form-control form-control-sm sub-change-product" value="${escapeHtml(subscription.product_name || '')}"></div> <div class="col-md-3"><label class="small text-muted">Produkt</label><input class="form-control form-control-sm sub-change-product" list="subscriptionProductOptions" value="${escapeHtml(subscription.product_name || '')}" disabled title="Start en ændringspakke for at vælge et nyt produkt"></div>
<div class="col-md-2"><label class="small text-muted">Samlet pris</label><div class="input-group input-group-sm"><input type="number" class="form-control sub-change-price" value="${Number(subscription.price || 0).toFixed(2)}" readonly><span class="input-group-text">kr.</span></div></div> <div class="col-md-2"><label class="small text-muted">Samlet pris</label><div class="input-group input-group-sm"><input type="number" class="form-control sub-change-price" value="${Number(subscription.price || 0).toFixed(2)}" readonly><span class="input-group-text">kr.</span></div></div>
<div class="col-md-2"><label class="small text-muted">Interval</label><select class="form-select form-select-sm sub-change-interval">${['daily','biweekly','monthly','quarterly','yearly'].map(v => `<option value="${v}" ${v === subscription.billing_interval ? 'selected' : ''}>${formatSubscriptionInterval(v)}</option>`).join('')}</select></div> <div class="col-md-2"><label class="small text-muted">Interval</label><select class="form-select form-select-sm sub-change-interval">${['daily','biweekly','monthly','quarterly','yearly'].map(v => `<option value="${v}" ${v === subscription.billing_interval ? 'selected' : ''}>${formatSubscriptionInterval(v)}</option>`).join('')}</select></div>
<div class="col-md-2"><label class="small text-muted">Fakturering</label><select class="form-select form-select-sm sub-change-schedule"><option value="fixed_day" ${subscription.billing_schedule_type === 'fixed_day' ? 'selected' : ''}>Fast dag</option><option value="first_business_day" ${subscription.billing_schedule_type === 'first_business_day' ? 'selected' : ''}>Første bankdag</option><option value="last_business_day" ${subscription.billing_schedule_type === 'last_business_day' ? 'selected' : ''}>Sidste bankdag</option><option value="interval_anchor" ${subscription.billing_schedule_type === 'interval_anchor' ? 'selected' : ''}>Fast interval</option></select></div> <div class="col-md-2"><label class="small text-muted">Fakturering</label><select class="form-select form-select-sm sub-change-schedule"><option value="fixed_day" ${subscription.billing_schedule_type === 'fixed_day' ? 'selected' : ''}>Fast dag</option><option value="first_business_day" ${subscription.billing_schedule_type === 'first_business_day' ? 'selected' : ''}>Første bankdag</option><option value="last_business_day" ${subscription.billing_schedule_type === 'last_business_day' ? 'selected' : ''}>Sidste bankdag</option><option value="interval_anchor" ${subscription.billing_schedule_type === 'interval_anchor' ? 'selected' : ''}>Fast interval</option></select></div>
@ -19794,11 +20123,23 @@
const count = overview.querySelectorAll('.subscription-change-select:checked').length; const count = overview.querySelectorAll('.subscription-change-select:checked').length;
const summary = document.getElementById('agreementSelectionSummary'); const summary = document.getElementById('agreementSelectionSummary');
if (summary) summary.textContent = count ? `${count} abonnement${count === 1 ? '' : 'er'} valgt` : 'Vælg ét eller flere abonnementer i kortene nedenfor.'; if (summary) summary.textContent = count ? `${count} abonnement${count === 1 ? '' : 'er'} valgt` : 'Vælg ét eller flere abonnementer i kortene nedenfor.';
syncAgreementChangeMode();
})); }));
overview.querySelectorAll('.subscription-agreement-card').forEach(card => refreshAgreementChangePreview(card)); overview.querySelectorAll('.subscription-agreement-card').forEach(card => refreshAgreementChangePreview(card));
overview.addEventListener('input', event => { overview.addEventListener('input', event => {
const card = event.target.closest('.subscription-agreement-card'); const card = event.target.closest('.subscription-agreement-card');
if (card && event.target.matches('.sub-change-line input, .sub-change-interval, .sub-change-schedule, .sub-change-day')) refreshAgreementChangePreview(card); if (card && event.target.matches('.sub-change-line input, .sub-change-interval, .sub-change-schedule, .sub-change-day')) refreshAgreementChangePreview(card);
if (card && event.target.matches('.sub-change-product')) {
const product = subscriptionProducts.find(item => item.name === event.target.value.trim());
if (!product) return;
const firstLine = card.querySelector('.sub-change-line');
if (!firstLine) return;
const description = firstLine.querySelector('.sub-change-line-description');
const price = firstLine.querySelector('.sub-change-line-price');
if (description) description.value = product.short_description || product.name || '';
if (price && product.sales_price != null) price.value = Number(product.sales_price).toFixed(2);
refreshAgreementChangePreview(card);
}
}); });
overview.addEventListener('click', event => { overview.addEventListener('click', event => {
const card = event.target.closest('.subscription-agreement-card'); const card = event.target.closest('.subscription-agreement-card');
@ -19812,6 +20153,7 @@
}); });
document.getElementById('agreementEffectiveDate')?.addEventListener('input', () => overview.querySelectorAll('.subscription-agreement-card').forEach(card => refreshAgreementChangePreview(card))); document.getElementById('agreementEffectiveDate')?.addEventListener('input', () => overview.querySelectorAll('.subscription-agreement-card').forEach(card => refreshAgreementChangePreview(card)));
setupAgreementSearch(); setupAgreementSearch();
syncAgreementChangeMode();
setSubscriptionBadge(payload.agreement_status); setSubscriptionBadge(payload.agreement_status);
} }
@ -19842,6 +20184,9 @@
function openAgreementChangeComposer() { function openAgreementChangeComposer() {
const selected = document.querySelectorAll('.subscription-change-select:checked').length; const selected = document.querySelectorAll('.subscription-change-select:checked').length;
if (!selected) return showCaseFeedback('Markér først abonnementet, du vil ændre');
agreementChangeMode = true;
syncAgreementChangeMode();
const composer = document.getElementById('agreementChangeComposer'); const composer = document.getElementById('agreementChangeComposer');
composer?.classList.remove('d-none'); composer?.classList.remove('d-none');
const dateInput = document.getElementById('agreementEffectiveDate'); const dateInput = document.getElementById('agreementEffectiveDate');
@ -19979,8 +20324,51 @@
throw new Error(error.detail || 'Kunne ikke opdatere status'); throw new Error(error.detail || 'Kunne ikke opdatere status');
} }
const updated = await res.json(); await res.json();
renderSubscription(updated); await loadSubscriptionForCase();
} catch (e) {
showCaseFeedback(e.message || e);
}
}
function agreementSubscriptionById(subscriptionId) {
return [...(currentSubscriptionAgreement?.current_subscriptions || []), ...(currentSubscriptionAgreement?.upcoming_subscriptions || [])]
.find(item => Number(item.id) === Number(subscriptionId));
}
function editDraftFromAgreement(subscriptionId) {
const subscription = agreementSubscriptionById(subscriptionId);
if (!subscription) return showCaseFeedback('Kunne ikke finde abonnementet');
currentSubscription = subscription;
subscriptionEditMode = true;
document.getElementById('subscriptionAgreementOverview')?.classList.add('d-none');
renderSubscriptionEditForm(subscription);
}
async function activateDraftFromAgreement(subscriptionId) {
const subscription = agreementSubscriptionById(subscriptionId);
if (!subscription) return showCaseFeedback('Kunne ikke finde abonnementet');
if (!confirm('Aktivér abonnementet? Det bliver herefter klar til fakturering på den planlagte dato.')) return;
currentSubscription = subscription;
await updateSubscriptionStatus('active');
}
async function processSubscriptionFromAgreement(subscriptionId) {
const subscription = agreementSubscriptionById(subscriptionId);
if (!subscription) return showCaseFeedback('Kunne ikke finde abonnementet');
currentSubscription = subscription;
await processCurrentSubscriptionInvoice();
}
async function processCurrentSubscriptionInvoice() {
if (!currentSubscription) return;
if (!confirm('Opret en ordrekladde for dette abonnement nu? Andre abonnementer bliver ikke kørt.')) return;
try {
const res = await fetch(`/api/v1/sag-subscriptions/${currentSubscription.id}/process-invoice`, { method: 'POST' });
const payload = await res.json();
if (!res.ok) throw new Error(payload.detail || 'Kunne ikke køre fakturering');
showCaseFeedback(payload.message || 'Fakturering gennemført');
await loadSubscriptionForCase();
} catch (e) { } catch (e) {
showCaseFeedback(e.message || e); showCaseFeedback(e.message || e);
} }

View File

@ -0,0 +1,15 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Indkøbsoversigt{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4"><div><div class="text-uppercase small text-primary fw-bold">Drift · indkøb</div><h1 class="h3 mb-1">Varekø og levering</h1><p class="text-muted mb-0">Alt, der mangler at blive bestilt, modtaget eller sendt til kunden.</p></div><button class="btn btn-outline-primary" onclick="loadProcurement()"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div class="row g-3 mb-4" id="procurementKpis"></div>
<div class="card border-0 shadow-sm rounded-4"><div class="card-body p-0"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead class="table-light"><tr><th class="ps-4">Status</th><th>Vare</th><th>Sag / kunde</th><th>Antal</th><th>Indkøb</th><th>Salgsordre</th><th class="pe-4"></th></tr></thead><tbody id="procurementRows"><tr><td colspan="7" class="text-center py-5 text-muted">Henter indkøbskø…</td></tr></tbody></table></div></div></div>
</div>
<script>
const stateLabel={to_order:['Skal bestilles','danger'],ordered:['Bestilt','warning'],received:['Modtaget','success']};
const money=v=>new Intl.NumberFormat('da-DK',{style:'currency',currency:'DKK'}).format(Number(v||0));
async function loadProcurement(){const res=await fetch('/api/v1/procurement/overview');const data=await res.json();const c=data.counts||{};document.getElementById('procurementKpis').innerHTML=[['Skal bestilles',c.to_order,'danger','cart-plus'],['Bestilt',c.ordered,'warning','truck'],['Modtaget',c.received,'success','box-seam'],['Mangler salgsordre',c.missing_sales_order,'secondary','receipt']].map(([l,n,col,i])=>`<div class="col-sm-6 col-xl-3"><div class="card border-0 shadow-sm rounded-4"><div class="card-body d-flex justify-content-between"><div><div class="small text-muted">${l}</div><div class="display-6 fw-bold text-${col}">${n||0}</div></div><i class="bi bi-${i} fs-3 text-${col} opacity-75"></i></div></div></div>`).join('');document.getElementById('procurementRows').innerHTML=(data.items||[]).map(x=>{const s=stateLabel[x.fulfilment_state]||stateLabel.to_order;return `<tr><td class="ps-4"><span class="badge text-bg-${s[1]}">${s[0]}</span></td><td><strong>${x.description||'Uden beskrivelse'}</strong><div class="small text-muted">${x.external_ref||'Ingen reference'}</div></td><td><a href="/sag/${x.sag_id}/v3">#${x.sag_id} · ${x.case_title||''}</a><div class="small text-muted">${x.customer_name||'Ingen kunde'}</div></td><td>${x.quantity||'—'} ${x.unit||''}</td><td>${money(x.amount)}</td><td>${x.has_sales_line?'<span class="text-success"><i class="bi bi-check-circle me-1"></i>Oprettet</span>':'<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>Mangler</span>'}</td><td class="pe-4"><a class="btn btn-sm btn-outline-primary" href="/sag/${x.sag_id}/v3?tab=sales">Åbn sag</a></td></tr>`}).join('')||'<tr><td colspan="7" class="text-center py-5 text-success">Ingen åbne indkøbslinjer.</td></tr>'}
loadProcurement();
</script>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Automatiske opgavelister{% endblock %}
{% block content %}
<div class="container py-4 py-lg-5 reminder-rules-page">
<section class="rule-hero mb-4"><div><div class="eyebrow hero-eyebrow">SUPPORT · AUTOMATISERING</div><h1>Automatiske opgavelister</h1><p>Få dine åbne sager og valgfrit gruppens samlet i en kort, klikbar besked på de tidspunkter, der passer din arbejdsdag.</p></div><div class="hero-icon"></div></section>
<div class="card rule-card border-0 shadow-sm"><div class="card-body p-4 p-lg-5"><div class="d-flex align-items-start gap-3 mb-4"><div class="section-icon"></div><div><h2 class="h4 mb-1">Ny regel</h2><p class="text-muted mb-0">Reglen kører automatisk; brug <strong>Send nu</strong> for at teste den med det samme.</p></div></div><div class="row g-4"><div class="col-lg-6"><label class="form-label fw-semibold" for="ruleTitle">Navn på listen</label><input id="ruleTitle" class="form-control form-control-lg" value="Min opgaveliste"><div class="form-text">Kun til din egen oversigt.</div></div><div class="col-lg-6"><label class="form-label fw-semibold" for="ruleTimes">Tidspunkter</label><input id="ruleTimes" class="form-control form-control-lg" value="09:00, 12:00, 14:00" placeholder="09:00, 12:00"><div class="form-text">Skriv flere tider adskilt med komma, fx 09:00, 12:00, 14:00.</div></div><div class="col-12"><div class="rule-options"><label class="form-check option"><input id="ruleGroups" class="form-check-input" type="checkbox" checked><span><strong>Medtag mine gruppers sager</strong><small>Viser også åbne sager, som er tildelt dine grupper.</small></span></label><label class="form-check option"><input id="ruleMattermost" class="form-check-input" type="checkbox" checked><span><strong>Send til Mattermost</strong><small>Hver sag i beskeden får et direkte link til Hub.</small></span></label></div></div><div class="col-12 d-flex justify-content-end"><button id="saveButton" class="btn btn-primary px-4 py-2" onclick="saveRule()">Gem regel</button></div></div></div></div>
<section class="mt-5"><div class="mb-3"><div class="eyebrow">DINE REGLER</div><h2 class="h4 mb-0">Planlagte opgavelister</h2></div><div id="rules"></div></section>
</div>
<style>.reminder-rules-page{max-width:1180px}.rule-hero{background:linear-gradient(120deg,#0b3e68,#126d91 65%,#18a6a6);color:#fff;border-radius:24px;padding:34px 40px;display:flex;justify-content:space-between;align-items:center}.rule-hero h1{font-size:clamp(1.7rem,3vw,2.4rem);margin:.35rem 0 .6rem;font-weight:750}.rule-hero p{margin:0;max-width:680px;color:#d8edf5;font-size:1.05rem}.eyebrow{font-size:.73rem;letter-spacing:.11em;font-weight:800;color:#5a7890}.hero-eyebrow{color:#a9edf0}.hero-icon{width:74px;height:74px;border-radius:50%;display:grid;place-items:center;font-size:2.4rem;font-weight:700;background:#ffffff22;border:1px solid #ffffff40}.rule-card{border-radius:22px}.section-icon{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:#e6f4fa;color:#0c6895;font-size:1.35rem}.rule-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.option{margin:0;border:1px solid #dce8ef;border-radius:14px;padding:16px 17px 16px 40px;background:#fbfdff}.option .form-check-input{margin-left:-24px;margin-top:.35rem}.option small{display:block;color:#6a7c89;margin-top:3px}.rule-entry{border:1px solid #dde8ef;border-radius:17px;background:#fff;padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px}.rule-time{display:inline-flex;border-radius:999px;padding:5px 10px;background:#edf6fa;color:#155c82;font-size:.86rem;font-weight:650}.empty-rules{border:1px dashed #bfd2df;border-radius:16px;padding:28px;color:#647684;text-align:center;background:#fcfeff}@media(max-width:700px){.rule-hero{padding:27px}.hero-icon{display:none}.rule-options{grid-template-columns:1fr}.rule-entry{align-items:flex-start;flex-direction:column}}</style>
<script>
const rulesEl=document.getElementById('rules');
const escapeHtml=value=>String(value||'').replace(/[&<>'"]/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
async function responseMessage(response,fallback){const body=await response.json().catch(()=>({}));return body.detail||body.message||fallback}
async function load(){const response=await fetch('/api/v1/reminder-task-rules');if(!response.ok){rulesEl.innerHTML=`<div class="alert alert-danger">${escapeHtml(await responseMessage(response,'Kunne ikke hente regler'))}</div>`;return}const list=await response.json();rulesEl.innerHTML=list.map(rule=>`<article class="rule-entry"><div><div class="fw-bold fs-5">${escapeHtml(rule.title)}</div><div class="mt-2">${(rule.times_json||[]).map(time=>`<span class="rule-time me-1">${escapeHtml(time)}</span>`).join('')} ${rule.include_groups?'<span class="text-muted small ms-1">· Mine grupper er med</span>':''}</div></div><button class="btn btn-outline-primary" onclick="sendRule(${rule.id},this)">Send nu ↗</button></article>`).join('')||'<div class="empty-rules">Ingen regler endnu. Opret den første ovenfor, og test den derefter med <strong>Send nu</strong>.</div>'}
async function saveRule(){const button=document.getElementById('saveButton');const times=document.getElementById('ruleTimes').value.split(',').map(value=>value.trim()).filter(Boolean);if(!times.length)return alert('Vælg mindst ét tidspunkt');button.disabled=true;button.textContent='Gemmer…';try{const response=await fetch('/api/v1/reminder-task-rules',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.getElementById('ruleTitle').value,times,include_groups:document.getElementById('ruleGroups').checked,notify_mattermost:document.getElementById('ruleMattermost').checked})});if(!response.ok)throw new Error(await responseMessage(response,'Kunne ikke gemme'));await load()}catch(error){alert(error.message)}finally{button.disabled=false;button.textContent='Gem regel'}}
async function sendRule(id,button){const original=button.innerHTML;button.disabled=true;button.textContent='Sender…';try{const response=await fetch(`/api/v1/reminder-task-rules/${id}/send-now`,{method:'POST'});const result=await response.json().catch(()=>({}));if(!response.ok||!result.sent)throw new Error(result.detail||result.message||'Kunne ikke sende');alert(`Opgavelisten er sendt med ${result.count} sager.`)}catch(error){alert(error.message)}finally{button.disabled=false;button.innerHTML=original}}
load();
</script>
{% endblock %}

View File

@ -136,6 +136,23 @@ class EmailProcessorService:
} }
try: try:
# Do not let a Graph metadata-only attachment turn into a completed
# invoice workflow. This also protects historic rows imported before
# attachment byte retrieval was made reliable.
attachment_state = execute_query(
"""SELECT has_attachments,
EXISTS(SELECT 1 FROM email_attachments WHERE email_id = %s) AS has_saved_attachment
FROM email_messages WHERE id = %s""",
(email_id, email_id),
) if email_id else []
if attachment_state:
state = attachment_state[0]
if state.get('has_attachments') and not state.get('has_saved_attachment'):
await self._set_awaiting_user_action(email_id, reason='missing_attachment_content')
stats['awaiting_user_action'] = True
logger.warning("🛑 Email %s was not processed because its attachment is missing", email_id)
return stats
# Step 2.5: Detect and transcribe audio attachments # Step 2.5: Detect and transcribe audio attachments
# This is done BEFORE classification so the AI can "read" the voice note # This is done BEFORE classification so the AI can "read" the voice note
if settings.WHISPER_ENABLED: if settings.WHISPER_ENABLED:

View File

@ -447,6 +447,11 @@ class EmailService:
) )
parsed_email['attachments'] = attachments parsed_email['attachments'] = attachments
parsed_email['attachment_count'] = len(attachments) parsed_email['attachment_count'] = len(attachments)
# `hasAttachments` is Graph metadata, not a guarantee that
# contentBytes was included in the list response. Keep this
# fact so the processor can never silently mark an invoice as
# completed without its actual file.
parsed_email['attachment_fetch_failed'] = not bool(attachments)
else: else:
parsed_email['attachments'] = [] parsed_email['attachments'] = []
@ -891,7 +896,24 @@ class EmailService:
import base64 import base64
content = base64.b64decode(content_bytes) content = base64.b64decode(content_bytes)
else: else:
# Graph may omit contentBytes for larger fileAttachment
# objects. Fetch the attachment stream explicitly instead
# of creating an empty, unusable attachment record.
attachment_id = att.get('id')
content = b'' content = b''
if attachment_id:
value_url = (
f"https://graph.microsoft.com/v1.0/users/{user_email}"
f"/messages/{message_id}/attachments/{attachment_id}/$value"
)
async with session.get(value_url, headers=headers) as value_response:
if value_response.status == 200:
content = await value_response.read()
else:
logger.warning(
"⚠️ Failed to download attachment bytes for %s/%s: %s",
message_id, attachment_id, value_response.status,
)
# Handle missing filenames for audio (FALLBACK) # Handle missing filenames for audio (FALLBACK)
filename = att.get('name') filename = att.get('name')
@ -905,6 +927,10 @@ class EmailService:
filename = f"audio_attachment{ext}" filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}") logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}")
if not content:
logger.warning("⚠️ Skipping empty Graph attachment %s", filename or attachment_id)
continue
attachments.append({ attachments.append({
'filename': filename or 'unknown', 'filename': filename or 'unknown',
'content': content, 'content': content,
@ -919,6 +945,84 @@ class EmailService:
return attachments return attachments
async def recover_graph_attachments(self, email_id: int) -> Dict[str, Any]:
"""Recover missing attachments for an already imported Graph email.
We store the internet message-id rather than Graph's opaque message id,
so first resolve it through Graph and then download the real bytes.
This is deliberately limited to a single known email; it never creates a
new email record or changes customer allocations.
"""
row = execute_query(
"""SELECT id, message_id, subject, has_attachments
FROM email_messages WHERE id = %s AND deleted_at IS NULL""",
(email_id,),
)
if not row:
return {"success": False, "reason": "Email not found"}
email_row = row[0]
if not email_row.get("has_attachments"):
return {"success": False, "reason": "Email has no declared attachments"}
if not self.use_graph or not self._graph_send_available():
return {"success": False, "reason": "Microsoft Graph is not configured"}
access_token = await self._get_graph_access_token()
if not access_token:
return {"success": False, "reason": "Could not authenticate to Microsoft Graph"}
user_email = self.graph_config["user_email"]
message_id = str(email_row.get("message_id") or "")
if not message_id:
return {"success": False, "reason": "Email has no message id"}
headers = {"Authorization": f"Bearer {access_token}"}
params = {
"$filter": "internetMessageId eq '{}'".format(message_id.replace("'", "''")),
"$select": "id,subject,hasAttachments",
"$top": 2,
}
try:
async with ClientSession() as session:
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
detail = await response.text()
logger.warning("⚠️ Could not resolve Graph email %s: %s %s", email_id, response.status, detail)
return {"success": False, "reason": "Email could not be found in Microsoft Graph"}
matches = (await response.json()).get("value", [])
if not matches:
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}
graph_message = matches[0]
attachments = await self._fetch_graph_attachments(
user_email, graph_message["id"], access_token, session
)
if not attachments:
return {"success": False, "reason": "Graph returned no downloadable attachments"}
await self._save_attachments(email_id, attachments)
saved = execute_query(
"SELECT COUNT(*) AS count FROM email_attachments WHERE email_id = %s",
(email_id,),
)
saved_count = int((saved[0] if saved else {}).get("count") or 0)
if not saved_count:
return {"success": False, "reason": "Attachment could not be saved"}
execute_update(
"""UPDATE email_messages
SET has_attachments = true, attachment_count = %s,
status = 'new', auto_processed = false, processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(saved_count, email_id),
)
return {"success": True, "attachments_recovered": len(attachments), "attachments_saved": saved_count}
except Exception as exc:
logger.exception("❌ Failed recovering Graph attachments for email %s", email_id)
return {"success": False, "reason": str(exc)}
def _decode_header(self, header: str) -> str: def _decode_header(self, header: str) -> str:
"""Decode email header (handles MIME encoding)""" """Decode email header (handles MIME encoding)"""
if not header: if not header:
@ -1167,6 +1271,20 @@ class EmailService:
if email_data.get('attachments'): if email_data.get('attachments'):
await self._save_attachments(email_id, email_data['attachments']) await self._save_attachments(email_id, email_data['attachments'])
# A mail that Graph says has attachments, but where no file could be
# retrieved, must be visible for review and must not enter automatic
# invoice processing as a successful/empty mail.
if email_data.get('has_attachments') and not email_data.get('attachments'):
execute_update(
"""UPDATE email_messages
SET status = 'awaiting_user_action', auto_processed = false,
processed_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(email_id,),
)
email_data['attachment_fetch_failed'] = True
logger.warning("⚠️ Email %s has declared attachments but none were saved", email_id)
return email_id return email_id
except Exception as e: except Exception as e:
@ -1477,9 +1595,47 @@ class EmailService:
existing = execute_query(check_query, (email_data["message_id"],)) existing = execute_query(check_query, (email_data["message_id"],))
if existing: if existing:
# A user may upload the original .eml again specifically to
# restore an attachment that was missing in an old Graph import.
# Treat that as a repair, not as a dead duplicate.
email_id = int(existing[0]["id"])
incoming_attachments = email_data.get("attachments") or []
if not incoming_attachments:
logger.info(f"⏭️ Email already exists: {email_data['message_id']}") logger.info(f"⏭️ Email already exists: {email_data['message_id']}")
return None return None
saved_rows = execute_query(
"SELECT filename FROM email_attachments WHERE email_id = %s",
(email_id,),
) or []
saved_names = {str(row.get("filename") or "") for row in saved_rows}
missing_attachments = [
item for item in incoming_attachments
if str(item.get("filename") or "") not in saved_names
]
if missing_attachments:
await self._save_attachments(email_id, missing_attachments)
count_row = execute_query(
"SELECT COUNT(*) AS count FROM email_attachments WHERE email_id = %s",
(email_id,),
) or []
saved_count = int((count_row[0] if count_row else {}).get("count") or 0)
if not saved_count:
logger.warning("⚠️ Existing upload %s still has no saved attachments", email_id)
return None
execute_update(
"""UPDATE email_messages
SET has_attachments = true, attachment_count = %s,
status = 'new', auto_processed = false, processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(saved_count, email_id),
)
logger.info("✅ Restored %s attachment(s) on existing email %s", len(missing_attachments), email_id)
return email_id
# Insert email # Insert email
thread_key = self._derive_thread_key(email_data) thread_key = self._derive_thread_key(email_data)
try: try:

View File

@ -1370,6 +1370,7 @@ class EmailWorkflowService:
steps = workflow['workflow_steps'] steps = workflow['workflow_steps']
steps_completed = 0 steps_completed = 0
step_results = [] step_results = []
has_failed_step = False
try: try:
# Execute each step # Execute each step
@ -1377,6 +1378,18 @@ class EmailWorkflowService:
action = step.get('action') action = step.get('action')
params = step.get('params', {}) params = step.get('params', {})
# A final "mark as processed" must never conceal a failed
# invoice extraction (or any other earlier workflow failure).
if action == 'mark_as_processed' and has_failed_step:
step_results.append({
'step': idx + 1,
'action': action,
'status': 'skipped',
'result': None,
'error': 'Skipped because an earlier workflow step failed',
})
continue
logger.info(f" ➡️ Step {idx + 1}/{len(steps)}: {action}") logger.info(f" ➡️ Step {idx + 1}/{len(steps)}: {action}")
step_result = await self._execute_action(action, params, email_data) step_result = await self._execute_action(action, params, email_data)
@ -1389,6 +1402,7 @@ class EmailWorkflowService:
}) })
if step_result['status'] == 'failed': if step_result['status'] == 'failed':
has_failed_step = True
logger.error(f" ❌ Step failed: {step_result.get('error')}") logger.error(f" ❌ Step failed: {step_result.get('error')}")
# Continue to next step even on failure (configurable later) # Continue to next step even on failure (configurable later)
else: else:
@ -1396,37 +1410,49 @@ class EmailWorkflowService:
steps_completed += 1 steps_completed += 1
# Mark execution as completed # Preserve a real failure in the execution record instead of
# reporting a green workflow with red individual steps.
completed_at = datetime.now() completed_at = datetime.now()
execution_time_ms = int((completed_at - started_at).total_seconds() * 1000) execution_time_ms = int((completed_at - started_at).total_seconds() * 1000)
execution_status = 'failed' if has_failed_step else 'completed'
execute_update( execute_update(
"""UPDATE email_workflow_executions """UPDATE email_workflow_executions
SET status = 'completed', steps_completed = %s, SET status = %s, steps_completed = %s,
result_json = %s, completed_at = CURRENT_TIMESTAMP, result_json = %s, completed_at = CURRENT_TIMESTAMP,
execution_time_ms = %s execution_time_ms = %s
WHERE id = %s""", WHERE id = %s""",
(steps_completed, json.dumps(step_results), execution_time_ms, execution_id) (execution_status, steps_completed, json.dumps(step_results), execution_time_ms, execution_id)
) )
# Update workflow statistics # Update workflow statistics
execute_update( execute_update(
"""UPDATE email_workflows """UPDATE email_workflows
SET execution_count = execution_count + 1, SET execution_count = execution_count + 1,
success_count = success_count + 1, success_count = success_count + %s,
failure_count = failure_count + %s,
last_executed_at = CURRENT_TIMESTAMP last_executed_at = CURRENT_TIMESTAMP
WHERE id = %s""", WHERE id = %s""",
(workflow_id,) (0 if has_failed_step else 1, 1 if has_failed_step else 0, workflow_id)
) )
logger.info(f"✅ Workflow '{workflow_name}' completed ({execution_time_ms}ms)") if has_failed_step:
execute_update(
"""UPDATE email_messages
SET status = 'awaiting_user_action', auto_processed = false,
processed_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(email_id,),
)
logger.info("%s Workflow '%s' %s (%sms)", "" if not has_failed_step else "⚠️", workflow_name, execution_status, execution_time_ms)
# Log: Workflow execution completed # Log: Workflow execution completed
await email_activity_logger.log_workflow_executed( await email_activity_logger.log_workflow_executed(
email_id=email_id, email_id=email_id,
workflow_id=workflow_id, workflow_id=workflow_id,
workflow_name=workflow_name, workflow_name=workflow_name,
status='completed', status=execution_status,
steps_completed=steps_completed, steps_completed=steps_completed,
execution_time_ms=execution_time_ms execution_time_ms=execution_time_ms
) )
@ -1435,7 +1461,7 @@ class EmailWorkflowService:
'workflow_id': workflow_id, 'workflow_id': workflow_id,
'workflow_name': workflow_name, 'workflow_name': workflow_name,
'execution_id': execution_id, 'execution_id': execution_id,
'status': 'completed', 'status': execution_status,
'steps_completed': steps_completed, 'steps_completed': steps_completed,
'steps_total': len(steps), 'steps_total': len(steps),
'execution_time_ms': execution_time_ms, 'execution_time_ms': execution_time_ms,
@ -1515,6 +1541,12 @@ class EmailWorkflowService:
} }
result = await handler(params, email_data) result = await handler(params, email_data)
if isinstance(result, dict) and result.get('success') is False:
return {
'status': 'failed',
'result': result,
'error': result.get('note') or result.get('reason') or f"Action {action} did not complete",
}
return { return {
'status': 'success', 'status': 'success',
'result': result 'result': result
@ -1817,7 +1849,9 @@ class EmailWorkflowService:
attachments = execute_query( attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type """SELECT filename, file_path, size_bytes, content_type
FROM email_attachments FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""", WHERE email_id = %s
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
OR LOWER(filename) LIKE '%%.pdf')""",
(email_id,) (email_id,)
) )

View File

@ -112,7 +112,22 @@ def billing_date_for_period(
target = period_start - relativedelta(months=max(0, int(lead_months or 0))) target = period_start - relativedelta(months=max(0, int(lead_months or 0)))
if schedule_type == "interval_anchor": if schedule_type == "interval_anchor":
return target return target
return resolve_month_date(target.year, target.month, schedule_type, billing_day) resolved = resolve_month_date(target.year, target.month, schedule_type, billing_day)
# With no billing lead, an arbitrary period start (for example 31 August)
# must never yield an invoice date that has already passed (1 August).
# In that situation the first valid scheduled invoice date is in the next
# calendar month. A positive lead intentionally permits a date before the
# coverage period and is left unchanged.
if int(lead_months or 0) == 0 and resolved < period_start:
following_month = target + relativedelta(months=1)
resolved = resolve_month_date(
following_month.year,
following_month.month,
schedule_type,
billing_day,
)
return resolved
def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date: def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date:

View File

@ -1838,6 +1838,7 @@
<button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-label">Ulæste mails</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Ulæste mails: 0</span></button> <button class="bb-chip" type="button" data-bb-key="mail"><i class="bi bi-envelope"></i> <span class="bb-chip-label">Ulæste mails</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Ulæste mails: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-label">Hastesager</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Hastesager: 0</span></button> <button class="bb-chip" type="button" data-bb-key="urgent"><i class="bi bi-exclamation-octagon"></i> <span class="bb-chip-label">Hastesager</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Hastesager: 0</span></button>
<button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button> <button class="bb-chip" type="button" data-bb-key="unassigned"><i class="bi bi-person-x"></i> <span class="bb-chip-label">Uden ansvarlig</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Uden ansvarlig: 0</span></button>
<a class="bb-chip" href="/procurement" data-bb-key="procurement"><i class="bi bi-cart-plus"></i> <span class="bb-chip-label">Indkøb</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Skal bestilles: 0</span></a>
<a class="bb-chip" href="/drift" data-bb-key="drift"><i class="bi bi-broadcast"></i> <span class="bb-chip-label">Drift</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Drift: 0</span></a> <a class="bb-chip" href="/drift" data-bb-key="drift"><i class="bi bi-broadcast"></i> <span class="bb-chip-label">Drift</span> <span class="bb-chip-bubble" aria-hidden="true">0</span> <span class="bb-chip-text visually-hidden">Drift: 0</span></a>
</div> </div>
<div class="bb-zone bb-zone-center"> <div class="bb-zone bb-zone-center">
@ -2001,7 +2002,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.5"></script> <script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/sms.js?v=1.1"></script> <script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script> <script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.68"></script> <script src="/static/js/bottom-bar.js?v=2.69"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true; window.BMC_CAN_CLICK_TO_CALL = true;
@ -2048,6 +2049,7 @@ if (bmcOriginalFetch) {
if (!li) return false; if (!li) return false;
const anchor = li.querySelector('a.dropdown-item'); const anchor = li.querySelector('a.dropdown-item');
if (!anchor) return false; if (!anchor) return false;
if (li.classList.contains('bmc-menu-hidden') || anchor.classList.contains('bmc-menu-hidden')) return false;
if (li.style.display === 'none') return false; if (li.style.display === 'none') return false;
if (anchor.style.display === 'none') return false; if (anchor.style.display === 'none') return false;
return true; return true;
@ -2124,7 +2126,14 @@ if (bmcOriginalFetch) {
document.querySelectorAll('[data-menu-key]').forEach((node) => { document.querySelectorAll('[data-menu-key]').forEach((node) => {
const key = String(node.getAttribute('data-menu-key') || '').trim().toLowerCase(); const key = String(node.getAttribute('data-menu-key') || '').trim().toLowerCase();
if (!key) return; if (!key) return;
node.style.display = hidden.has(key) ? 'none' : ''; const shouldHide = hidden.has(key);
node.classList.toggle('bmc-menu-hidden', shouldHide);
node.hidden = shouldHide;
if (shouldHide) {
node.style.setProperty('display', 'none', 'important');
} else {
node.style.removeProperty('display');
}
}); });
cleanupNavbarDropdowns(hidden); cleanupNavbarDropdowns(hidden);
window.__bmcMenuHiddenKeys = Array.from(hidden); window.__bmcMenuHiddenKeys = Array.from(hidden);
@ -2907,7 +2916,12 @@ if (bmcOriginalFetch) {
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-menu-tab" data-bs-toggle="tab" data-bs-target="#profile-menu" type="button" role="tab"> <button class="nav-link" id="profile-task-lists-tab" data-bs-toggle="tab" data-bs-target="#profile-task-lists" type="button" role="tab">
Opgavelister
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-menu-tab" data-bs-toggle="tab" data-bs-target="#profile-menu" type="button" role="tab" onclick="loadProfileMenuPreferences()">
Menu Menu
</button> </button>
</li> </li>
@ -3012,18 +3026,36 @@ if (bmcOriginalFetch) {
</div> </div>
</div> </div>
<div class="tab-pane fade" id="profile-task-lists" role="tabpanel" tabindex="0">
<div class="card border-0 bg-light-subtle">
<div class="card-body">
<div class="d-flex align-items-start gap-3 mb-3">
<div class="rounded-3 text-primary bg-primary-subtle d-grid" style="width:42px;height:42px;place-items:center"><i class="bi bi-list-check fs-5"></i></div>
<div><h6 class="mb-1 fw-bold">Automatiske opgavelister</h6><p class="small text-muted mb-0">Send dine egne og gruppens åbne sager på faste tidspunkter. Beskeden indeholder direkte links til sagerne.</p></div>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-6"><label class="form-label small fw-semibold" for="profileTaskListTitle">Navn</label><input id="profileTaskListTitle" class="form-control" value="Min opgaveliste"></div>
<div class="col-md-6"><label class="form-label small fw-semibold" for="profileTaskListTimes">Tidspunkter</label><input id="profileTaskListTimes" class="form-control" value="09:00, 12:00, 14:00"><div class="form-text">Fx 09:00, 12:00, 14:00</div></div>
<div class="col-12 d-flex flex-wrap gap-4"><label class="form-check mb-0"><input id="profileTaskListGroups" class="form-check-input" type="checkbox" checked><span class="form-check-label">Medtag mine gruppers sager</span></label><label class="form-check mb-0"><input id="profileTaskListMattermost" class="form-check-input" type="checkbox" checked><span class="form-check-label">Send til Mattermost</span></label></div>
<div class="col-12 d-flex justify-content-end"><button id="profileTaskListSave" class="btn btn-primary btn-sm px-3" type="button" onclick="saveProfileTaskList()"><i class="bi bi-plus-lg me-1"></i>Gem opgaveliste</button></div>
</div>
</div>
</div>
<div class="mt-3" id="profileTaskLists"><div class="p-3 text-muted small">Indlæser opgavelister...</div></div>
</div>
<div class="tab-pane fade" id="profile-menu" role="tabpanel" tabindex="0"> <div class="tab-pane fade" id="profile-menu" role="tabpanel" tabindex="0">
<div class="card border-0"> <div class="card border-0">
<div class="card-body px-0"> <div class="card-body px-0">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-primary"><i class="bi bi-layout-text-window-reverse me-2"></i>Menuvisning (min konto)</h6> <h6 class="mb-0 text-primary"><i class="bi bi-layout-text-window-reverse me-2"></i>Menuvisning (min konto)</h6>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" type="button" id="profMenuShowAllBtn">Vis alle</button> <button class="btn btn-sm btn-outline-secondary" type="button" id="profMenuShowAllBtn" onclick="showAllProfileMenuItems()">Vis alle</button>
<button class="btn btn-sm btn-primary" type="button" id="profMenuSaveBtn">Gem</button> <button class="btn btn-sm btn-primary" type="button" id="profMenuSaveBtn" onclick="saveProfileMenuPreferences()">Gem</button>
</div> </div>
</div> </div>
<div id="profMenuFeedback" class="small text-muted mb-2"></div> <div id="profMenuFeedback" class="small text-muted mb-2">Vælg hvilke menupunkter der skal vises for dig.</div>
<div id="profMenuPrefsGrid" class="row g-2"></div> <div id="profMenuPrefsGrid" class="row g-2"><div class="col-12 text-muted small py-3"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser menuindstillinger…</div></div>
</div> </div>
</div> </div>
</div> </div>
@ -3036,6 +3068,10 @@ if (bmcOriginalFetch) {
</div> </div>
</div> </div>
<style>
/* Personal menu preferences must also win over responsive navbar layout rules. */
#navbarNav .bmc-menu-hidden { display: none !important; }
</style>
<script> <script>
const PROFILE_MENU_PREF_ITEMS = [ const PROFILE_MENU_PREF_ITEMS = [
{ key: 'menu-crm', label: 'CRM' }, { key: 'menu-crm', label: 'CRM' },
@ -3164,6 +3200,9 @@ if (bmcOriginalFetch) {
window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', { window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
detail: { hidden_menu_keys: hiddenKeys } detail: { hidden_menu_keys: hiddenKeys }
})); }));
// Apply immediately as well. This avoids relying on a custom event
// when a page has scripts from an older browser cache.
applyMenuVisibility(hiddenKeys);
setProfileMenuFeedback('Menu gemt.', 'success'); setProfileMenuFeedback('Menu gemt.', 'success');
} catch (e) { } catch (e) {
setProfileMenuFeedback(e.message || 'Kunne ikke gemme menu', 'error'); setProfileMenuFeedback(e.message || 'Kunne ikke gemme menu', 'error');
@ -3304,6 +3343,59 @@ if (bmcOriginalFetch) {
} }
} }
async function loadProfileTaskLists() {
const target = document.getElementById('profileTaskLists');
if (!target) return;
try {
const res = await fetch('/api/v1/reminder-task-rules', { credentials: 'include' });
if (!res.ok) throw new Error('Kunne ikke hente opgavelister');
const rules = await res.json();
target.innerHTML = rules.length ? rules.map(rule => `
<div class="border rounded-3 bg-white p-3 mb-2 d-flex justify-content-between align-items-center gap-3">
<div><div class="fw-semibold">${escapeHtml(rule.title)}</div><div class="small text-muted mt-1"><i class="bi bi-clock me-1"></i>${(rule.times_json || []).map(escapeHtml).join(' · ')}${rule.include_groups ? ' · Mine grupper' : ''}</div></div>
<button class="btn btn-sm btn-outline-primary flex-shrink-0" type="button" onclick="sendProfileTaskList(${rule.id}, this)"><i class="bi bi-send me-1"></i>Send nu</button>
</div>`).join('') : '<div class="border rounded-3 bg-white p-3 text-muted small">Du har endnu ingen automatiske opgavelister.</div>';
} catch (e) {
target.innerHTML = `<div class="alert alert-danger py-2 small mb-0">${escapeHtml(e.message || 'Kunne ikke hente opgavelister.')}</div>`;
}
}
async function saveProfileTaskList() {
const button = document.getElementById('profileTaskListSave');
const times = (document.getElementById('profileTaskListTimes')?.value || '').split(',').map(value => value.trim()).filter(Boolean);
if (!times.length) return alert('Skriv mindst ét tidspunkt, fx 09:00.');
button.disabled = true;
const original = button.innerHTML;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Gemmer';
try {
const res = await fetch('/api/v1/reminder-task-rules', {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
title: document.getElementById('profileTaskListTitle').value,
times,
include_groups: document.getElementById('profileTaskListGroups').checked,
notify_mattermost: document.getElementById('profileTaskListMattermost').checked
})
});
const result = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(result.detail || 'Kunne ikke gemme opgavelisten');
await loadProfileTaskLists();
} catch (e) { alert('Fejl: ' + e.message); }
finally { button.disabled = false; button.innerHTML = original; }
}
async function sendProfileTaskList(ruleId, button) {
const original = button.innerHTML;
button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender';
try {
const res = await fetch(`/api/v1/reminder-task-rules/${ruleId}/send-now`, {method: 'POST', credentials: 'include'});
const result = await res.json().catch(() => ({}));
if (!res.ok || !result.sent) throw new Error(result.detail || result.message || 'Kunne ikke sende opgavelisten');
alert(`Opgavelisten er sendt med ${result.count} sager.`);
} catch (e) { alert('Fejl: ' + e.message); }
finally { button.disabled = false; button.innerHTML = original; }
}
async function loadUserProfile() { async function loadUserProfile() {
try { try {
const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' }); const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' });
@ -3443,11 +3535,6 @@ if (bmcOriginalFetch) {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadCurrentUserMenuIdentity(); loadCurrentUserMenuIdentity();
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
const showAllBtn = document.getElementById('profMenuShowAllBtn');
if (showAllBtn) showAllBtn.addEventListener('click', showAllProfileMenuItems);
const profileModalEl = document.getElementById('profileModal'); const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) { if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => { profileModalEl.addEventListener('shown.bs.modal', () => {
@ -3455,6 +3542,7 @@ if (bmcOriginalFetch) {
loadProfileReminders(); loadProfileReminders();
loadUserProfile(); loadUserProfile();
loadProfileMenuPreferences(); loadProfileMenuPreferences();
loadProfileTaskLists();
}); });
} }
}); });

View File

@ -1384,20 +1384,39 @@ async def update_subscription(
payload: Dict[str, Any], payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")), current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
): ):
"""Direct edits are limited to notes; business changes require an approved request.""" """A draft can be corrected directly; live agreements use change requests."""
try: try:
forbidden = set(payload) - {"notes"} subscription = execute_query_single(
"""SELECT id, status, period_start, billing_lead_months,
billing_schedule_type, billing_day
FROM sag_subscriptions WHERE id = %s""",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# A draft has not created an invoice or a committed agreement yet, so
# the creator must be able to correct it without a change case.
draft_fields = {
"product_name", "billing_interval", "billing_schedule_type", "billing_day", "price",
"start_date", "end_date", "period_start", "notice_period_days", "notes",
"billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
"binding_months", "binding_start_date", "binding_end_date", "binding_group_key",
"invoice_merge_key", "price_type", "custom_price_override", "first_invoice_policy",
"line_items", "first_invoice_items",
}
allowed_direct_fields = draft_fields if subscription.get("status") == "draft" else {"notes"}
forbidden = set(payload) - allowed_direct_fields
if forbidden: if forbidden:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail="Business fields must be changed through a subscription change request", detail="Business fields must be changed through a subscription change request",
) )
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s", # The case editor always posts this collection. First-invoice lines
(subscription_id,) # are intentionally left untouched here until their dedicated draft
) # editor is used; they are not a recurring agreement change.
if not subscription: payload.pop("first_invoice_items", None)
raise HTTPException(status_code=404, detail="Subscription not found")
# Extract line_items before processing other fields # Extract line_items before processing other fields
line_items = payload.pop("line_items", None) line_items = payload.pop("line_items", None)
@ -1441,9 +1460,22 @@ async def update_subscription(
else first_description else first_description
) )
if subscription.get("status") == "draft" and any(
field in payload for field in {"period_start", "billing_lead_months", "billing_schedule_type", "billing_day"}
):
period_start = _safe_date(payload.get("period_start") or subscription.get("period_start"))
if not period_start:
raise HTTPException(status_code=400, detail="period_start must be a valid date")
payload["next_invoice_date"] = billing_date_for_period(
period_start,
int(payload.get("billing_lead_months", subscription.get("billing_lead_months") or 0)),
payload.get("billing_schedule_type") or subscription.get("billing_schedule_type") or "fixed_day",
int(payload.get("billing_day", subscription.get("billing_day") or 1)),
)
# Build dynamic update query # Build dynamic update query
allowed_fields = { allowed_fields = {
"product_name", "billing_interval", "billing_day", "price", "product_name", "billing_interval", "billing_schedule_type", "billing_day", "price",
"start_date", "end_date", "next_invoice_date", "period_start", "start_date", "end_date", "next_invoice_date", "period_start",
"notice_period_days", "status", "notes", "notice_period_days", "status", "notes",
"billing_direction", "advance_months", "billing_lead_months", "first_full_period_start", "billing_direction", "advance_months", "billing_lead_months", "first_full_period_start",
@ -1531,9 +1563,27 @@ async def update_subscription(
@router.patch("/sag-subscriptions/{subscription_id}/status", response_model=Dict[str, Any]) @router.patch("/sag-subscriptions/{subscription_id}/status", response_model=Dict[str, Any])
async def update_subscription_status(subscription_id: int, payload: Dict[str, Any]): async def update_subscription_status(
"""Compatibility guard: lifecycle changes require four-eyes workflow.""" subscription_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(require_permission("subscriptions.change_request")),
):
"""Activate an initial draft. Changes to a live agreement still need approval."""
status = str(payload.get("status") or "").strip().lower()
subscription = execute_query_single(
"SELECT id, status FROM sag_subscriptions WHERE id = %s", (subscription_id,)
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet findes ikke")
if subscription.get("status") != "draft" or status != "active":
raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request") raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
execute_query(
"""UPDATE sag_subscriptions SET status = 'active', updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(subscription_id,),
fetch=False,
)
return _load_subscription_with_context(subscription_id)
@router.get("/sag-subscriptions", response_model=List[Dict[str, Any]]) @router.get("/sag-subscriptions", response_model=List[Dict[str, Any]])
@ -1762,6 +1812,28 @@ async def trigger_subscription_processing():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/sag-subscriptions/{subscription_id}/process-invoice")
async def trigger_single_subscription_processing(subscription_id: int):
"""Process one due subscription without starting billing for other customers."""
subscription = execute_query_single(
"SELECT id, status, next_invoice_date FROM sag_subscriptions WHERE id = %s",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Abonnementet findes ikke")
if subscription.get("status") != "active":
raise HTTPException(status_code=409, detail="Abonnementet skal være aktivt, før det kan faktureres")
if not subscription.get("next_invoice_date") or subscription["next_invoice_date"] > date.today():
raise HTTPException(status_code=409, detail="Abonnementet er ikke klar til fakturering endnu")
try:
from app.jobs.process_subscriptions import process_subscriptions
await process_subscriptions(subscription_ids=[subscription_id])
return {"status": "success", "message": "Abonnementets fakturering er kørt"}
except Exception as e:
logger.error("❌ Manual processing failed for subscription %s: %s", subscription_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sag-subscriptions/{subscription_id}/price-changes", response_model=List[Dict[str, Any]]) @router.get("/sag-subscriptions/{subscription_id}/price-changes", response_model=List[Dict[str, Any]])
async def list_subscription_price_changes(subscription_id: int): async def list_subscription_price_changes(subscription_id: int):
"""List planned price changes for one subscription.""" """List planned price changes for one subscription."""

View File

@ -258,17 +258,17 @@ class EmailTicketIntegration:
# Critical keywords # Critical keywords
if any(word in all_text for word in ['kritisk', 'critical', 'down', 'nede', 'urgent', 'akut']): if any(word in all_text for word in ['kritisk', 'critical', 'down', 'nede', 'urgent', 'akut']):
return TicketPriority.critical return TicketPriority.URGENT
# High priority keywords # High priority keywords
if any(word in all_text for word in ['høj', 'high', 'vigtig', 'important', 'haster']): if any(word in all_text for word in ['høj', 'high', 'vigtig', 'important', 'haster']):
return TicketPriority.high return TicketPriority.HIGH
# Low priority keywords # Low priority keywords
if any(word in all_text for word in ['lav', 'low', 'spørgsmål', 'question', 'info']): if any(word in all_text for word in ['lav', 'low', 'spørgsmål', 'question', 'info']):
return TicketPriority.low return TicketPriority.LOW
return TicketPriority.normal return TicketPriority.NORMAL
@staticmethod @staticmethod
def _format_description(email_data: Dict[str, Any]) -> str: def _format_description(email_data: Dict[str, Any]) -> str:

View File

@ -0,0 +1,16 @@
-- Local standard selling prices for products offered from a shared/delefiber.
CREATE TABLE IF NOT EXISTS internet_connections_delefiber_product_prices (
id SERIAL PRIMARY KEY,
connection_id INTEGER NOT NULL REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
monthly_price NUMERIC(12,2) NOT NULL CHECK (monthly_price >= 0),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (connection_id, product_id)
);
CREATE INDEX IF NOT EXISTS idx_delefiber_product_prices_connection
ON internet_connections_delefiber_product_prices(connection_id)
WHERE is_active = TRUE;

View File

@ -0,0 +1,32 @@
-- Apple BMC Mobile Recorder provisioning support.
-- The serial number remains the physical-device identity. Device-specific values
-- (UDID, ECID, IMEI, Wi-Fi MAC and iOS) live in hardware_specs for extensibility.
ALTER TABLE hardware_assets
ADD COLUMN IF NOT EXISTS recorder_number INTEGER,
ADD COLUMN IF NOT EXISTS provisioned_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS last_provisioned_at TIMESTAMP;
ALTER TABLE hardware_assets DROP CONSTRAINT IF EXISTS hardware_assets_asset_type_check;
ALTER TABLE hardware_assets ADD CONSTRAINT hardware_assets_asset_type_check
CHECK (asset_type IN ('pc', 'laptop', 'printer', 'skærm', 'telefon', 'server', 'netværk', 'andet', 'mobile_recorder'));
ALTER TABLE hardware_assets DROP CONSTRAINT IF EXISTS hardware_assets_status_check;
ALTER TABLE hardware_assets ADD CONSTRAINT hardware_assets_status_check
CHECK (status IN ('active', 'ready', 'faulty_reported', 'in_repair', 'replaced', 'retired', 'unsupported'));
CREATE INDEX IF NOT EXISTS idx_hardware_mobile_recorder_number
ON hardware_assets(recorder_number)
WHERE deleted_at IS NULL AND recorder_number IS NOT NULL;
CREATE TABLE IF NOT EXISTS hardware_provisioning_history (
id BIGSERIAL PRIMARY KEY,
hardware_id INTEGER NOT NULL REFERENCES hardware_assets(id) ON DELETE CASCADE,
action VARCHAR(16) NOT NULL CHECK (action IN ('created', 'updated')),
source VARCHAR(80) NOT NULL DEFAULT 'apple_configurator',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
provisioned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_hardware_provisioning_history_asset
ON hardware_provisioning_history(hardware_id, provisioned_at DESC);

View File

@ -0,0 +1,106 @@
-- Manual: Apple Configurator / cfgutil provisioning of BMC Mobile Recorders.
INSERT INTO manual_articles (title, slug, content, summary, module, tags, difficulty)
VALUES (
'Provisionér en BMC Mobile Recorder med Apple Configurator',
'provisioner-bmc-mobile-recorder-med-apple-configurator',
$guide$
Formål
Denne guide kobler provisioning-scriptet Macen direkte til Hub Assets. Når cfgutil er færdig med en iPhone, sender scriptet dens hardwaredata til Hub. Hub identificerer altid den fysiske telefon serienummeret.
Workflow
iPhone tilsluttes cfgutil provisionerer scriptet aflæser enhedsdata Hub opretter eller opdaterer Asset Recorder står som Ready.
Endpoint
POST /api/v1/assets/provision
Autentificering
Endpointet er kun til servicekonti og kræver ikke brugerlogin. Send token i én af disse headers:
X-Provisioning-Token: <token>
eller
Authorization: Bearer <token>
Hub-serveren skal token ligge som miljøvariabel:
MOBILE_RECORDER_PROVISIONING_TOKEN=<lang-tilfældig-hemmelig-token>
Sikkerhed
Gem aldrig token i Git, i et shared script eller i en e-mail. Gem den i Macens Keychain eller et lokalt, adgangsbeskyttet environment-script. Er token ikke sat Hub-serveren, afviser endpointet alle kald.
Vigtige regler
serial_number er den eneste identitet for den fysiske iPhone.
Findes serienummeret allerede blandt aktive Assets, opdateres samme Asset.
Findes serienummeret ikke, oprettes et nyt Asset.
Hvis to aktive Assets har samme serienummer, stopper Hub med HTTP 409. Flet dubletterne før provisioning fortsætter.
Provisioning sætter typen mobile_recorder, BMC som ejer og status ready.
Data som gemmes
Navn, recorder-nummer, Apple-producent, model/device type, serienummer, UDID, ECID, IMEI, WiFi MAC, iOS-version og supervised-status. Hvert kald gemmes også i Assetets provisioning-historik.
Eksempel JSON-payload
{
"name": "BMC Recorder #24",
"asset_type": "mobile_recorder",
"manufacturer": "Apple",
"recorder_number": 24,
"model": "iPhone 15",
"serial_number": "XXXXXXXX",
"udid": "XXXXXXXX",
"ecid": "XXXXXXXX",
"imei": "XXXXXXXX",
"wifi_mac": "XX:XX:XX:XX:XX:XX",
"os": "iOS",
"os_version": "18.0",
"supervised": true,
"status": "ready"
}
Succesrespons
{
"success": true,
"action": "created",
"asset_id": 1842,
"recorder_number": 24
}
action er created ved første registrering og updated ved senere provisioning af samme serienummer.
Fejlhåndtering
401: Forkert eller manglende token.
409: Flere aktive Assets bruger samme serienummer. Flet/ryd dubletten op først.
422: Payload mangler serienummer eller har en forkert asset_type/status.
503: MOBILE_RECORDER_PROVISIONING_TOKEN er ikke sat Hub-serveren.
Fremtidig udlevering
En Recorder oprettes som BMC-ejet og Ready. Når den udleveres, skal den efterfølgende knyttes til kunde og kontaktperson via Assetets ejerskabs- og kontaktfunktioner. Det bevarer historikken for udlevering, returnering og eventuel udskiftning.
$guide$,
'Provisionér en iPhone med cfgutil og opret eller opdatér automatisk BMC Mobile Recorder-assetet i Hub.',
'hardware',
'["apple", "cfgutil", "iphone", "mobile-recorder", "provisioning", "assets"]'::jsonb,
'advanced'
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
summary = EXCLUDED.summary,
module = EXCLUDED.module,
tags = EXCLUDED.tags,
difficulty = EXCLUDED.difficulty,
deleted_at = NULL,
updated_at = CURRENT_TIMESTAMP;
WITH article AS (
SELECT id FROM manual_articles
WHERE slug = 'provisioner-bmc-mobile-recorder-med-apple-configurator'
)
DELETE FROM manual_steps WHERE manual_id IN (SELECT id FROM article);
INSERT INTO manual_steps (manual_id, step_number, title, content)
SELECT article.id, step.step_number, step.title, step.content
FROM (SELECT id FROM manual_articles WHERE slug = 'provisioner-bmc-mobile-recorder-med-apple-configurator') article
CROSS JOIN (VALUES
(1, 'Sæt service-token på Hub', 'Sæt MOBILE_RECORDER_PROVISIONING_TOKEN som hemmelig miljøvariabel på Hub-serveren og genstart API-containeren. Brug en lang, tilfældig token.'),
(2, 'Gem token sikkert på provisioning-Mac', 'Læg token i Keychain eller et lokalt environment-script. Den må ikke committed til Git eller ligge i en fælles mappe.'),
(3, 'Provisionér iPhone med cfgutil', 'Kør den sædvanlige Apple Configurator/cfgutil-provisionering. Aflæs først device name, serienummer, UDID, ECID, IMEI, WiFi MAC, model og iOS-version.'),
(4, 'Post data til Hub', 'Kald POST /api/v1/assets/provision med Content-Type application/json og X-Provisioning-Token. Brug serienummeret fra den tilsluttede iPhone.'),
(5, 'Kontrollér responsen', 'success=true og action=created betyder nyt Asset. action=updated betyder, at samme fysiske iPhone er opdateret. Ved 409 skal serienummer-dubletter i Assets ryddes op før ny kørsel.'),
(6, 'Udlever eller returnér senere', 'Recorderen er nu BMC-ejet og Ready. Brug Assetets ejerskab/kontakter, når den udleveres til en kunde eller bruger, så historikken holdes samlet.')
) AS step(step_number, title, content);

View File

@ -805,6 +805,7 @@
cases: Number(cases.open || 0), cases: Number(cases.open || 0),
urgent: Number(urgent.count || 0), urgent: Number(urgent.count || 0),
unassigned: Number(unassigned.count || 0), unassigned: Number(unassigned.count || 0),
procurement: Number((sections.procurement || {}).to_order || 0),
timer: Number(timer.active_count || 0), timer: Number(timer.active_count || 0),
drift: Number(drift.down || 0), drift: Number(drift.down || 0),
eset: Number(eset.incidents || 0) eset: Number(eset.incidents || 0)
@ -818,6 +819,7 @@
cases: 'Åbne sager', cases: 'Åbne sager',
urgent: 'Hastesager', urgent: 'Hastesager',
unassigned: 'Sager uden ansvarlig', unassigned: 'Sager uden ansvarlig',
procurement: 'Varer der skal bestilles',
timer: 'Aktive timere', timer: 'Aktive timere',
drift: 'Drift alerts', drift: 'Drift alerts',
eset: 'ESET incidents' eset: 'ESET incidents'
@ -844,6 +846,7 @@
if (val > 0) return 'sev-warn'; if (val > 0) return 'sev-warn';
return 'sev-ok'; return 'sev-ok';
} }
if (key === 'procurement') return val > 0 ? 'sev-critical' : 'sev-ok';
return val > 0 ? 'sev-warn' : 'sev-ok'; return val > 0 ? 'sev-warn' : 'sev-ok';
} }
@ -1130,6 +1133,7 @@
cases: 'Sager', cases: 'Sager',
urgent: 'Hastesager', urgent: 'Hastesager',
unassigned: 'Uden ansvarlig', unassigned: 'Uden ansvarlig',
procurement: 'Skal bestilles',
timer: 'Timere', timer: 'Timere',
drift: 'Drift', drift: 'Drift',
eset: 'ESET' eset: 'ESET'

View File

@ -0,0 +1,73 @@
import asyncio
from app.modules.hardware.backend import router as hardware_router
def _payload(**overrides):
data = {
"name": "BMC Recorder #24",
"recorder_number": 24,
"serial_number": "SERIAL-24",
"udid": "udid-24",
"ecid": "ecid-24",
"imei": "imei-24",
"wifi_mac": "AA:BB:CC:DD:EE:FF",
"os_version": "18.0",
"supervised": True,
}
data.update(overrides)
return hardware_router.MobileRecorderProvisionRequest(**data)
def test_mobile_recorder_provisioning_creates_asset_by_serial(monkeypatch):
calls = []
def fake_query(query, params=None, fetch=True):
calls.append((query, params, fetch))
if "SELECT id, hardware_specs" in query:
return []
if "INSERT INTO hardware_assets" in query:
return [{"id": 1842}]
return []
monkeypatch.setattr(hardware_router, "execute_query", fake_query)
monkeypatch.setattr(hardware_router.settings, "MOBILE_RECORDER_PROVISIONING_TOKEN", "provision-token")
result = asyncio.run(hardware_router.provision_mobile_recorder(_payload(), "Bearer provision-token", None))
assert result == {"success": True, "action": "created", "asset_id": 1842, "recorder_number": 24}
insert = next(params for query, params, _ in calls if "INSERT INTO hardware_assets" in query)
assert insert[2] == "SERIAL-24"
assert insert[5].adapted["mobile_recorder"]["supervised"] is True
def test_mobile_recorder_provisioning_updates_existing_serial(monkeypatch):
calls = []
def fake_query(query, params=None, fetch=True):
calls.append((query, params, fetch))
if "SELECT id, hardware_specs" in query:
return [{"id": 99, "hardware_specs": {"other_connector": {"keep": True}}}]
if "UPDATE hardware_assets" in query:
return [{"id": 99}]
return []
monkeypatch.setattr(hardware_router, "execute_query", fake_query)
monkeypatch.setattr(hardware_router.settings, "MOBILE_RECORDER_PROVISIONING_TOKEN", "provision-token")
result = asyncio.run(hardware_router.provision_mobile_recorder(_payload(), None, "provision-token"))
assert result["action"] == "updated"
update = next(params for query, params, _ in calls if "UPDATE hardware_assets" in query)
assert update[-1] == 99
assert update[4].adapted["other_connector"] == {"keep": True}
assert update[4].adapted["mobile_recorder"]["udid"] == "udid-24"
def test_mobile_recorder_provisioning_requires_service_token(monkeypatch):
monkeypatch.setattr(hardware_router.settings, "MOBILE_RECORDER_PROVISIONING_TOKEN", "provision-token")
try:
asyncio.run(hardware_router.provision_mobile_recorder(_payload(), None, "wrong"))
assert False, "Expected an authentication error"
except hardware_router.HTTPException as exc:
assert exc.status_code == 401

View File

@ -55,6 +55,12 @@ def test_period_can_be_invoiced_two_months_in_advance():
assert billing_date_for_period(date(2027, 1, 1), 2, "fixed_day", 1) == date(2026, 11, 1) assert billing_date_for_period(date(2027, 1, 1), 2, "fixed_day", 1) == date(2026, 11, 1)
def test_first_invoice_uses_next_scheduled_day_when_period_started_later_in_month():
# A subscription that begins on 31 August and invoices on day 1 must start
# on 1 September, not retrospectively on 1 August.
assert billing_date_for_period(date(2026, 8, 31), 0, "fixed_day", 1) == date(2026, 9, 1)
def test_three_monthly_periods_cover_a_quarter(): def test_three_monthly_periods_cover_a_quarter():
assert advance_billing_periods(date(2027, 1, 1), "monthly", 3) == date(2027, 4, 1) assert advance_billing_periods(date(2027, 1, 1), "monthly", 3) == date(2027, 4, 1)