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:
parent
1cfe5aee76
commit
9ca562745a
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 SMS’er 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/modules/sag/templates/procurement_overview.html
Normal file
15
app/modules/sag/templates/procurement_overview.html
Normal 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 %}
|
||||||
19
app/modules/sag/templates/reminder_rules.html
Normal file
19
app/modules/sag/templates/reminder_rules.html
Normal 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
@ -918,6 +944,84 @@ class EmailService:
|
|||||||
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")
|
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")
|
||||||
|
|
||||||
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)"""
|
||||||
@ -1166,6 +1270,20 @@ class EmailService:
|
|||||||
# Save attachments if any
|
# Save attachments if any
|
||||||
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
|
||||||
|
|
||||||
@ -1477,8 +1595,46 @@ class EmailService:
|
|||||||
existing = execute_query(check_query, (email_data["message_id"],))
|
existing = execute_query(check_query, (email_data["message_id"],))
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
logger.info(f"⏭️ Email already exists: {email_data['message_id']}")
|
# A user may upload the original .eml again specifically to
|
||||||
return None
|
# 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']}")
|
||||||
|
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)
|
||||||
|
|||||||
@ -1370,12 +1370,25 @@ 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
|
||||||
for idx, step in enumerate(steps):
|
for idx, step in enumerate(steps):
|
||||||
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}")
|
||||||
|
|
||||||
@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(f"✅ Workflow '{workflow_name}' completed ({execution_time_ms}ms)")
|
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,)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
@ -1440,10 +1459,23 @@ async def update_subscription(
|
|||||||
if len(normalized_line_items) > 1
|
if len(normalized_line_items) > 1
|
||||||
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,
|
||||||
raise HTTPException(status_code=409, detail="Status must be changed through a subscription change request")
|
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")
|
||||||
|
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."""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
16
migrations/235_delefiber_product_prices.sql
Normal file
16
migrations/235_delefiber_product_prices.sql
Normal 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;
|
||||||
32
migrations/236_mobile_recorder_provisioning.sql
Normal file
32
migrations/236_mobile_recorder_provisioning.sql
Normal 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);
|
||||||
106
migrations/237_manual_mobile_recorder_provisioning.sql
Normal file
106
migrations/237_manual_mobile_recorder_provisioning.sql
Normal 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 på Mac’en 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 på 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>
|
||||||
|
|
||||||
|
På 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 Mac’ens Keychain eller et lokalt, adgangsbeskyttet environment-script. Er token ikke sat på 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, Wi‑Fi MAC, iOS-version og supervised-status. Hvert kald gemmes også i Assetets provisioning-historik.
|
||||||
|
|
||||||
|
Eksempel på 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 på 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, Wi‑Fi 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);
|
||||||
@ -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'
|
||||||
|
|||||||
73
tests/test_mobile_recorder_provisioning.py
Normal file
73
tests/test_mobile_recorder_provisioning.py
Normal 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
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user