release: v2.6.0

This commit is contained in:
Christian 2026-08-25 01:09:45 +02:00
parent 6906568dc2
commit d0f27b78d5
28 changed files with 3130 additions and 873 deletions

View File

@ -97,6 +97,30 @@ FEDEX_TIMEOUT_SECONDS=20
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser
FEDEX_READ_ONLY=true
FEDEX_DRY_RUN=true
# =====================================================
# Shipmondo Integration (Optional)
# Opret API-bruger og API-nøgle i Shipmondo under Indstillinger > API.
# Brug https://sandbox.shipmondo.com/api/public/v3 til sandbox.
# =====================================================
SHIPMONDO_ENABLED=false
SHIPMONDO_API_BASE_URL=https://app.shipmondo.com/api/public/v3
SHIPMONDO_API_USER=
SHIPMONDO_API_KEY=
SHIPMONDO_TIMEOUT_SECONDS=30
SHIPMONDO_SENDER_NAME=BMC Networks
SHIPMONDO_SENDER_ATTENTION=
SHIPMONDO_SENDER_ADDRESS1=
SHIPMONDO_SENDER_ADDRESS2=
SHIPMONDO_SENDER_POSTAL_CODE=
SHIPMONDO_SENDER_CITY=
SHIPMONDO_SENDER_COUNTRY_CODE=DK
SHIPMONDO_SENDER_EMAIL=
SHIPMONDO_SENDER_PHONE=
# Start sikkert: drafts er tilladt, men booking er blokeret.
SHIPMONDO_READ_ONLY=true
SHIPMONDO_DRY_RUN=true
# =====================================================
# Nextcloud Integration (Optional)
# =====================================================

View File

@ -117,6 +117,28 @@ FEDEX_TIMEOUT_SECONDS=20
FEDEX_READ_ONLY=true
FEDEX_DRY_RUN=true
# =====================================================
# Shipmondo Integration - Production
# =====================================================
SHIPMONDO_ENABLED=false
SHIPMONDO_API_BASE_URL=https://app.shipmondo.com/api/public/v3
SHIPMONDO_API_USER=
SHIPMONDO_API_KEY=
SHIPMONDO_TIMEOUT_SECONDS=30
SHIPMONDO_SENDER_NAME=BMC Networks
SHIPMONDO_SENDER_ATTENTION=
SHIPMONDO_SENDER_ADDRESS1=
SHIPMONDO_SENDER_ADDRESS2=
SHIPMONDO_SENDER_POSTAL_CODE=
SHIPMONDO_SENDER_CITY=
SHIPMONDO_SENDER_COUNTRY_CODE=DK
SHIPMONDO_SENDER_EMAIL=
SHIPMONDO_SENDER_PHONE=
# Start ALTID med begge sat til true.
SHIPMONDO_READ_ONLY=true
SHIPMONDO_DRY_RUN=true
# =====================================================
# Links / Endpoints Module - Production (Optional)
# =====================================================

View File

@ -0,0 +1,34 @@
# Release Notes: v2.6.0
**Dato:** 25. august 2026
## Overblik
Version 2.6.0 udvider BMC Hub med Shipmondo-forsendelser, en samlet afregningsgang for tidskøen og en mere robust produktintegration. Sagsvisningen har samtidig fået forbedret historik og arbejdsgange omkring produkter og forsendelser.
## Shipmondo
- Ny Shipmondo-integration med produktoversigt, lokale bookingkladder, afsendelse, tracking og PDF-labels.
- Forsendelser knyttes til sag, kunde og kontakt og gemmes med pakker, status og API-resultat.
- Sikker standardopsætning med integrationen deaktiveret samt `read-only` og `dry-run` aktiveret.
- Nye miljøindstillinger og Docker-konfiguration til API-adgang og afsenderoplysninger.
## Tidskø og afregning
- Valgte tidsregistreringer kan forhåndsvalideres og afregnes samlet.
- Understøttelse af ordrekladde, klippekort og ikke-fakturerbar afregning uden at blande betalingsmetoder.
- Oprettede ordrekladder og afregningsoplysninger spores direkte på tidsregistreringerne, så dobbeltbehandling undgås.
- Kundeidentifikation prioriterer sagens virksomhed og håndterer ældre trackingdata som fallback.
## Produkter og sager
- Produktdata fra API Gateway normaliseres på tværs af leverandørernes forskellige feltnavne og payload-formater.
- Produktsøgning, sortering og import er gjort mere robust ved manglende eller indlejrede data.
- Købs- og salgslinjer kan oprettes atomisk på en sag fra én produkthandling.
- Sagsdetaljen og historikken viser flere relevante handlinger og tidsstempler.
## Verifikation
- Målrettede tests for sager, tidskø, produkter og Shipmondo: **45 bestået**.
- Python-kompilering og diff-kontrol er gennemført.
- Den samlede vedligeholdte testsuite har 131 beståede og 7 eksisterende fejl i databaseafhængige eller andre ikke-berørte moduler.

View File

@ -1 +1 @@
2.5.1
2.6.0

View File

@ -326,6 +326,24 @@ class Settings(BaseSettings):
FEDEX_BASE_URL: str = ""
FEDEX_TIMEOUT_SECONDS: int = 20
# Shipmondo Integration
SHIPMONDO_ENABLED: bool = False
SHIPMONDO_READ_ONLY: bool = True
SHIPMONDO_DRY_RUN: bool = True
SHIPMONDO_API_BASE_URL: str = "https://app.shipmondo.com/api/public/v3"
SHIPMONDO_API_USER: str = ""
SHIPMONDO_API_KEY: str = ""
SHIPMONDO_TIMEOUT_SECONDS: int = 30
SHIPMONDO_SENDER_NAME: str = "BMC Networks"
SHIPMONDO_SENDER_ATTENTION: str = ""
SHIPMONDO_SENDER_ADDRESS1: str = ""
SHIPMONDO_SENDER_ADDRESS2: str = ""
SHIPMONDO_SENDER_POSTAL_CODE: str = ""
SHIPMONDO_SENDER_CITY: str = ""
SHIPMONDO_SENDER_COUNTRY_CODE: str = "DK"
SHIPMONDO_SENDER_EMAIL: str = ""
SHIPMONDO_SENDER_PHONE: str = ""
# ALSO Cloud Marketplace Integration
ALSO_ENABLED: bool = False
ALSO_READ_ONLY: bool = True

View File

@ -48,6 +48,41 @@ class BulkSendRequest(BaseModel):
ids: List[int] = Field(..., min_length=1)
class SettlementRequest(BaseModel):
"""Validate or complete settlement of selected time entries.
An explicit method deliberately applies to every selected line. Without it,
each line keeps its own selected method.
"""
ids: List[int] = Field(..., min_length=1)
billing_method: Optional[str] = None
prepaid_card_id: Optional[int] = Field(None, gt=0)
fixed_price_agreement_id: Optional[int] = Field(None, gt=0)
VALID_SETTLEMENT_METHODS = {"invoice", "prepaid", "subscription", "internal", "non_billable"}
def _normalise_billing_method(value: Optional[str]) -> str:
method = str(value or "invoice").strip().lower()
if method in {"prepaid_card", "clippekort"}:
return "prepaid"
if method in {"fixed_price", "abonnement"}:
return "subscription"
return method
def _settlement_label(method: str) -> str:
return {
"invoice": "Faktura",
"prepaid": "Klippekort",
"subscription": "Abonnement / fast pris",
"internal": "Intern tid",
"non_billable": "Ikke-fakturerbar tid",
}.get(method, method)
def _ensure_ids(ids: List[int]) -> List[int]:
clean = sorted(set(int(i) for i in ids if int(i) > 0))
if not clean:
@ -55,6 +90,22 @@ def _ensure_ids(ids: List[int]) -> List[int]:
return clean
def _hours_for_prepaid_card(row: Dict[str, Any], rounding_minutes: int) -> float:
"""Calculate a card debit per registration using the card's own rounding.
Rounding a combined total undercharges short registrations. The actual
duration is therefore rounded individually before the hours are added.
"""
actual_minutes = row.get("faktisk_tid_min")
if actual_minutes is None:
actual_minutes = round(float(row.get("original_hours") or 0) * 60)
actual_minutes = max(0, int(actual_minutes or 0))
if actual_minutes == 0:
return 0.0
block = max(1, int(rounding_minutes or row.get("round_block_min") or 30))
return ((actual_minutes + block - 1) // block * block) / 60.0
@router.get("/time-queue")
async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0),
@ -68,12 +119,15 @@ async def list_hub_time_queue(
conditions = [
"t.vtiger_id IS NULL",
"t.billed_via_thehub_id IS NULL",
"t.economy_order_draft_id IS NULL",
"t.status <> 'billed'",
]
params: List[Any] = []
# A time entry may retain an old customer_id after a case has been
# reassigned. The case is the source of truth whenever it has a customer.
if customer_id is not None:
conditions.append("t.customer_id = %s")
conditions.append("COALESCE(s.customer_id, effective_customer.hub_customer_id) = %s")
params.append(customer_id)
if status:
@ -88,7 +142,7 @@ async def list_hub_time_queue(
conditions.append(
"("
"COALESCE(t.description, '') ILIKE %s OR "
"COALESCE(cust.name, '') ILIKE %s OR "
"COALESCE(case_customer.name, effective_customer.name, '') ILIKE %s OR "
"COALESCE(c.title, s.titel, '') ILIKE %s"
")"
)
@ -100,8 +154,9 @@ async def list_hub_time_queue(
query = f"""
SELECT
t.id,
t.customer_id,
cust.name AS customer_name,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
t.customer_id AS recorded_customer_id,
t.status,
t.entry_status,
t.billable,
@ -110,20 +165,47 @@ async def list_hub_time_queue(
t.fixed_price_agreement_id,
t.original_hours,
t.approved_hours,
t.faktisk_tid_min,
t.fakturerbar_tid_min,
t.round_block_min,
t.rounded_to,
t.worked_date,
t.description,
t.entry_type,
t.work_type,
t.kilde,
t.case_id,
t.sag_id,
COALESCE(c.title, s.titel, 'No title') AS case_title,
COALESCE(c.title, s.titel, 'Ingen sagstitel') AS case_title,
s.status AS case_status,
s.customer_id AS hub_customer_id,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id,
COALESCE(NULLIF(u.full_name, ''), NULLIF(u.username, ''), NULLIF(t.user_name, ''), 'Ukendt medarbejder') AS employee_name,
CONCAT_WS(' ', NULLIF(cont.first_name, ''), NULLIF(cont.last_name, '')) AS contact_name,
t.created_at,
t.updated_at
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
LEFT JOIN LATERAL (
SELECT tc.id
FROM tmodule_customers tc
WHERE tc.hub_customer_id = s.customer_id
ORDER BY tc.id ASC
LIMIT 1
) sag_customer ON s.customer_id IS NOT NULL
LEFT JOIN tmodule_customers effective_customer
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
LEFT JOIN users u ON u.user_id = t.medarbejder_id
LEFT JOIN LATERAL (
SELECT sk.contact_id
FROM sag_kontakter sk
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC NULLS LAST, sk.id ASC
LIMIT 1
) primary_contact ON TRUE
LEFT JOIN contacts cont ON cont.id = primary_contact.contact_id
WHERE {where_sql}
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
LIMIT %s
@ -146,17 +228,28 @@ async def list_time_queue_customers():
rows = execute_query(
"""
SELECT
t.customer_id,
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
COALESCE(case_customer.name, effective_customer.name, CONCAT('Kunde #', COALESCE(s.customer_id, effective_customer.hub_customer_id)::text)) AS customer_name,
COUNT(*)::int AS open_count
FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
WHERE t.customer_id IS NOT NULL
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
LEFT JOIN LATERAL (
SELECT tc.id
FROM tmodule_customers tc
WHERE tc.hub_customer_id = s.customer_id
ORDER BY tc.id ASC
LIMIT 1
) sag_customer ON s.customer_id IS NOT NULL
LEFT JOIN tmodule_customers effective_customer
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
WHERE COALESCE(s.customer_id, effective_customer.hub_customer_id) IS NOT NULL
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.economy_order_draft_id IS NULL
AND t.status = 'pending'
GROUP BY t.customer_id, cust.name
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
GROUP BY COALESCE(s.customer_id, effective_customer.hub_customer_id), case_customer.name, effective_customer.name
ORDER BY COALESCE(case_customer.name, effective_customer.name, CONCAT('Kunde #', COALESCE(s.customer_id, effective_customer.hub_customer_id)::text)) ASC
"""
)
return {"items": rows, "count": len(rows)}
@ -166,15 +259,24 @@ async def list_time_queue_customers():
@router.get("/time-queue/prepaid-cards")
async def list_prepaid_cards():
async def list_prepaid_cards(customer_id: Optional[int] = Query(None, gt=0)):
try:
where_sql = "WHERE status IN ('active', 'depleted')"
params: List[Any] = []
if customer_id is not None:
# Prepaid cards belong to Hub customers (not the historic
# tmodule_customers record stored on a time entry).
where_sql += " AND customer_id = %s"
params.append(customer_id)
cards = execute_query(
"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
f"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours,
remaining_hours, rounding_minutes, status, expires_at
FROM tticket_prepaid_cards
WHERE status IN ('active', 'depleted')
{where_sql}
ORDER BY remaining_hours DESC, id DESC
"""
""",
tuple(params),
)
return {"items": cards, "count": len(cards)}
except Exception as e:
@ -218,6 +320,7 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest):
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(values + ids))
@ -244,6 +347,7 @@ async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed'
""",
tuple([reason] + ids),
@ -283,6 +387,7 @@ async def bulk_approve_time_queue(payload: BulkApproveRequest):
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed'
"""
execute_update(query, tuple(params + ids))
@ -315,6 +420,7 @@ async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
WHERE id IN ({placeholders})
AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed'
""",
tuple([payload.prepaid_card_id] + ids),
@ -432,32 +538,26 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
return int(order_id)
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single(
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
"""Create an order draft for the actual Hub customer on the case.
`tmodule_customers` is legacy tracking data and can contain stale names or
outdated mappings. It must never decide which legal customer is invoiced.
"""
hub_customer = execute_query_single(
"""
SELECT id, name, standard_hourly_rate, standard_margin_percent,
special_freight_price, supplier_service_enrolled, invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
if not hub_customer:
raise HTTPException(status_code=404, detail=f"Kunden på sagen ({hub_customer_id}) findes ikke")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
hub_customer_id = customer.get("hub_customer_id")
hub_customer = None
if hub_customer_id:
hub_customer = execute_query_single(
"""
SELECT
standard_hourly_rate,
standard_margin_percent,
special_freight_price,
supplier_service_enrolled,
invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
customer_name = hub_customer.get("name") or f"Kunde {hub_customer_id}"
hourly_rate = Decimal(str(hub_customer.get("standard_hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
invoice_fee_amount = Decimal(
str(
@ -529,8 +629,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
"product_id": None,
"selected": True,
"amount": float(amount),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": group["sag_id"],
"time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None,
@ -544,7 +644,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
if special_freight_amount > 0:
line_payloads.append(
{
"line_key": f"freight:{hub_customer_id or customer_id}",
"line_key": f"freight:{hub_customer_id}",
"source_type": "freight",
"source_id": None,
"description": "Særlig fragtpris",
@ -555,8 +655,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
"product_id": None,
"selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
@ -567,7 +667,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append(
{
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
"line_key": f"invoice_fee:{hub_customer_id}",
"source_type": "invoice_fee",
"source_id": None,
"description": "Faktureringsgebyr",
@ -578,8 +678,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
"product_id": None,
"selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None,
"customer_name": customer.get("name") or f"Kunde {customer_id}",
"customer_id": int(hub_customer_id),
"customer_name": customer_name,
"sag_id": None,
"time_entry_ids": [],
"time_date": None,
@ -593,8 +693,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
draft_title = f"Timefaktura {customer_name} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id}"
draft = execute_query_single(
"""
@ -614,7 +714,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
""",
(
draft_title,
int(hub_customer_id) if hub_customer_id else None,
int(hub_customer_id),
json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue",
1,
@ -630,44 +730,12 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
"""Resolve the actual Hub customer that must be invoiced.
Accepts:
- direct tmodule customer id
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
The historic function name remains for callers, but its return value is a
`customers.id`. The case company is authoritative; tracking rows are only
a fallback for time entries without a case.
"""
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
return int(row["id"]) if row else None
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
row = execute_query_single(
"""
SELECT id
FROM tmodule_customers
WHERE hub_customer_id = %s
ORDER BY id ASC
LIMIT 1
""",
(hub_customer_id,),
)
return int(row["id"]) if row else None
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct = _find_by_tmodule_id(cid)
if direct:
return direct
mapped = _find_by_hub_customer_id(cid)
if mapped:
return mapped
if sag_id is not None:
try:
sid = int(sag_id)
@ -678,15 +746,321 @@ def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optiona
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id:
mapped = _find_by_hub_customer_id(int(hub_customer_id))
if mapped:
return mapped
return int(hub_customer_id)
if raw_customer_id is not None:
try:
cid = int(raw_customer_id)
except (TypeError, ValueError):
cid = None
if cid and cid > 0:
direct_hub_customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (cid,))
if direct_hub_customer:
return int(direct_hub_customer["id"])
tracking_customer = execute_query_single(
"SELECT hub_customer_id FROM tmodule_customers WHERE id = %s", (cid,)
)
if (tracking_customer or {}).get("hub_customer_id"):
return int(tracking_customer["hub_customer_id"])
return None
def _selected_time_entries(ids: List[int]) -> List[Dict[str, Any]]:
"""Fetch queue-eligible records once, with the context needed for validation."""
placeholders = ",".join(["%s"] * len(ids))
return execute_query(
f"""
SELECT
t.id, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
t.customer_id AS recorded_customer_id, t.case_id, t.sag_id, t.status, t.billable,
t.billing_method, t.prepaid_card_id, t.fixed_price_agreement_id,
t.original_hours, t.approved_hours, t.faktisk_tid_min,
t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description,
COALESCE(c.title, s.titel, 'Tidsregistrering') AS case_title,
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS hub_customer_id,
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
LEFT JOIN LATERAL (
SELECT tc.id
FROM tmodule_customers tc
WHERE tc.hub_customer_id = s.customer_id
ORDER BY tc.id ASC
LIMIT 1
) sag_customer ON s.customer_id IS NOT NULL
LEFT JOIN tmodule_customers effective_customer
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
WHERE t.id IN ({placeholders})
AND t.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL
AND t.economy_order_draft_id IS NULL
AND t.status <> 'billed'
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)), t.id
""",
tuple(ids),
) or []
def _active_prepaid_card(card_id: int) -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT id, card_number, customer_id, remaining_hours, rounding_minutes, expires_at
FROM tticket_prepaid_cards
WHERE id = %s AND status = 'active'
AND remaining_hours > 0
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
""",
(card_id,),
)
def _active_agreement(agreement_id: int) -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT id, agreement_number, customer_id, monthly_hours
FROM customer_fixed_price_agreements
WHERE id = %s AND status = 'active'
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
""",
(agreement_id,),
)
def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
ids = _ensure_ids(payload.ids)
override_method = _normalise_billing_method(payload.billing_method) if payload.billing_method else None
if override_method and override_method not in VALID_SETTLEMENT_METHODS:
raise HTTPException(status_code=400, detail="Ugyldig afregningstype")
rows = _selected_time_entries(ids)
found_ids = {int(row["id"]) for row in rows}
missing_ids = [entry_id for entry_id in ids if entry_id not in found_ids]
items: List[Dict[str, Any]] = []
invoice_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
prepaid_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
subscription_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
errors: List[Dict[str, Any]] = [
{"id": entry_id, "message": "Tiden findes ikke længere i køen"}
for entry_id in missing_ids
]
for row in rows:
method = override_method or _normalise_billing_method(row.get("billing_method"))
hours = float(row.get("approved_hours") or row.get("original_hours") or 0)
item = {
"id": int(row["id"]),
"title": row.get("case_title") or "Tidsregistrering",
"customer_name": row.get("customer_name") or "Ukendt kunde",
"hours": hours,
"method": method,
"method_label": _settlement_label(method),
"valid": True,
"message": None,
}
if method not in VALID_SETTLEMENT_METHODS:
item.update(valid=False, message="Vælg en gyldig afregningstype")
elif hours <= 0:
item.update(valid=False, message="Tiden mangler et positivt timeantal")
elif method == "invoice":
customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if not customer_id:
item.update(valid=False, message="Mangler tilknyttet kunde til fakturering")
elif row.get("billable") is False:
item.update(valid=False, message="Intern/ikke-fakturerbar tid kan ikke sendes til faktura")
else:
item["resolved_customer_id"] = customer_id
invoice_groups[int(customer_id)].append(row)
elif method == "prepaid":
card_id = payload.prepaid_card_id or row.get("prepaid_card_id")
card = _active_prepaid_card(int(card_id)) if card_id else None
expected_customer_id = row.get("billing_customer_id") or row.get("hub_customer_id")
if not card:
item.update(valid=False, message="Vælg et aktivt klippekort")
elif expected_customer_id and int(card["customer_id"]) != int(expected_customer_id):
item.update(valid=False, message="Klippekortet tilhører ikke kunden på sagen")
else:
item["prepaid_card_id"] = int(card["id"])
prepaid_groups[int(card["id"])].append(row)
elif method == "subscription":
agreement_id = payload.fixed_price_agreement_id or row.get("fixed_price_agreement_id")
agreement = _active_agreement(int(agreement_id)) if agreement_id else None
expected_customer_id = row.get("billing_customer_id") or row.get("hub_customer_id")
if not agreement:
item.update(valid=False, message="Vælg en aktiv abonnements- eller fastprisaftale")
elif expected_customer_id and int(agreement["customer_id"]) != int(expected_customer_id):
item.update(valid=False, message="Aftalen tilhører ikke kunden på sagen")
else:
item["fixed_price_agreement_id"] = int(agreement["id"])
subscription_groups[int(agreement["id"])].append(row)
if not item["valid"]:
errors.append({"id": item["id"], "message": item["message"]})
items.append(item)
invoice_preview = []
for customer_id, group_rows in invoice_groups.items():
customer = execute_query_single(
"SELECT name, COALESCE(standard_hourly_rate, %s) AS hourly_rate FROM customers WHERE id = %s",
(settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id),
) or {}
hours = sum(
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
for row in group_rows
)
rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)
invoice_preview.append({
"customer_id": customer_id,
"customer_name": customer.get("name") or f"Kunde #{customer_id}",
"entries": [int(row["id"]) for row in group_rows],
"hours": hours,
"hourly_rate": rate,
"amount_ex_vat": round(hours * rate, 2),
})
prepaid_preview = []
for card_id, group_rows in prepaid_groups.items():
card = _active_prepaid_card(card_id) or {}
hours = sum(float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows)
if hours > float(card.get("remaining_hours") or 0):
message = f"Klippekortet mangler {round(hours - float(card.get('remaining_hours') or 0), 2)} timer"
for item in items:
if item.get("prepaid_card_id") == card_id:
item.update(valid=False, message=message)
errors.append({"id": item["id"], "message": message})
prepaid_preview.append({"card_id": card_id, "card_number": card.get("card_number"), "hours": hours, "remaining_hours": float(card.get("remaining_hours") or 0), "rounding_minutes": int(card.get("rounding_minutes") or 0)})
return {
"valid": not errors,
"selected": len(ids),
"items": items,
"errors": errors,
"invoice_groups": invoice_preview,
"prepaid_groups": prepaid_preview,
"subscription_groups": [
{"agreement_id": agreement_id, "entries": [int(row["id"]) for row in group_rows], "hours": sum(float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows)}
for agreement_id, group_rows in subscription_groups.items()
],
}
@router.post("/time-queue/preview-settlement")
async def preview_time_queue_settlement(payload: SettlementRequest):
"""Validate selected time and return a reviewable settlement preview. No writes."""
return _build_settlement_preview(payload)
@router.post("/time-queue/settle")
async def settle_time_queue(payload: SettlementRequest, request: Request):
"""Complete a previously reviewable settlement without mixing payment methods."""
preview = _build_settlement_preview(payload)
if not preview["valid"]:
raise HTTPException(status_code=409, detail={"message": "Ret fejlene før afregning", "preview": preview})
ids = _ensure_ids(payload.ids)
rows = _selected_time_entries(ids)
user_id = getattr(request.state, "user_id", None)
by_method: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for row in rows:
method = _normalise_billing_method(payload.billing_method or row.get("billing_method"))
by_method[method].append(row)
created_drafts = []
settled_ids: List[int] = []
for method, method_rows in by_method.items():
if method == "invoice":
by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in method_rows:
customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
if customer_id:
by_customer[int(customer_id)].append(row)
for customer_id, customer_rows in by_customer.items():
draft_id = _create_ordre_draft_from_selected(customer_id, customer_rows, user_id)
customer_entry_ids = [int(row["id"]) for row in customer_rows]
placeholders = ",".join(["%s"] * len(customer_entry_ids))
execute_update(
f"""UPDATE tmodule_times SET status = 'approved', entry_status = 'godkendt',
approved_hours = COALESCE(approved_hours, original_hours), approved_at = CURRENT_TIMESTAMP,
economy_order_draft_id = %s, economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id IN ({placeholders}) AND status <> 'billed'""",
tuple([draft_id, user_id] + customer_entry_ids),
)
settled_ids.extend(customer_entry_ids)
created_drafts.append({"customer_id": customer_id, "draft_id": draft_id, "entry_ids": customer_entry_ids})
elif method == "prepaid":
by_card: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in method_rows:
card_id = payload.prepaid_card_id or row.get("prepaid_card_id")
by_card[int(card_id)].append(row)
for card_id, card_rows in by_card.items():
card = _active_prepaid_card(card_id)
if not card:
raise HTTPException(status_code=409, detail="Klippekortet er ikke længere aktivt")
hours = sum(
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
for row in card_rows
)
debited = execute_query(
"""UPDATE tticket_prepaid_cards SET used_hours = used_hours + %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND status = 'active' AND remaining_hours >= %s
RETURNING id, remaining_hours""",
(hours, card_id, hours),
)
if not debited:
raise HTTPException(status_code=409, detail="Klippekortet har ikke længere nok timer")
entry_ids = [int(row["id"]) for row in card_rows]
execute_insert(
"""INSERT INTO tticket_prepaid_transactions (card_id, transaction_type, hours, balance_after, description, created_by_user_id)
VALUES (%s, 'usage', %s, %s, %s, %s) RETURNING id""",
(card_id, -hours, debited[0]["remaining_hours"], f"Tidskø: {', '.join(map(str, entry_ids))}", user_id),
)
placeholders = ",".join(["%s"] * len(entry_ids))
execute_update(
f"""UPDATE tmodule_times SET status = 'billed', entry_status = 'godkendt', billable = TRUE,
billing_method = 'prepaid', prepaid_card_id = %s, approved_hours = COALESCE(approved_hours, original_hours),
approved_at = CURRENT_TIMESTAMP, economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})""",
tuple([card_id, user_id] + entry_ids),
)
settled_ids.extend(entry_ids)
else:
entry_ids = [int(row["id"]) for row in method_rows]
agreement_id = payload.fixed_price_agreement_id if method == "subscription" else None
if method == "subscription" and agreement_id is None:
agreement_id = method_rows[0].get("fixed_price_agreement_id")
placeholders = ",".join(["%s"] * len(entry_ids))
execute_update(
f"""UPDATE tmodule_times SET status = 'billed', entry_status = 'godkendt',
billable = %s, billing_method = %s, fixed_price_agreement_id = %s,
approved_hours = COALESCE(approved_hours, original_hours), approved_at = CURRENT_TIMESTAMP,
economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})""",
tuple([False, method, agreement_id, user_id] + entry_ids),
)
settled_ids.extend(entry_ids)
return {
"success": True,
"settled_ids": sorted(set(settled_ids)),
"created_drafts": created_drafts,
"orders_url": f"/ordre/{created_drafts[0]['draft_id']}" if len(created_drafts) == 1 else "/ordre",
"message": "Tiderne er afregnet. Ordrekladder er fortsat lokale og skal godkendes fra Ordre.",
}
@router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
# Backwards-compatible endpoint for older clients. It now explicitly uses
# the invoice path rather than silently invoicing whatever method a row had.
return await settle_time_queue(
SettlementRequest(ids=payload.ids, billing_method="invoice"), request
)
ids = _ensure_ids(payload.ids)
user_id = getattr(request.state, "user_id", None)

View File

@ -1,510 +1,65 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Economy Time Queue{% endblock %}
{% block title %}Tidskø til afregning{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
<div>
<h2 class="mb-1">Economy Time Queue</h2>
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
</div>
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
</div>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="filterCustomer" class="form-label">Firma</label>
<select id="filterCustomer" class="form-select">
<option value="">Alle firmaer med ubehandlede registreringer</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterStatus" class="form-label">Status</label>
<select id="filterStatus" class="form-select">
<option value="">All</option>
<option value="pending">pending</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterBillable" class="form-label">Billable</label>
<select id="filterBillable" class="form-select">
<option value="">All</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</div>
<div class="col-12 col-md-3">
<label for="filterQuery" class="form-label">Search</label>
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label for="bulkDescription" class="form-label">Description</label>
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
</div>
<div class="col-12 col-md-2">
<label for="bulkHours" class="form-label">Hours</label>
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
</div>
<div class="col-12 col-md-2">
<label for="bulkBillingMethod" class="form-label">Billing method</label>
<select id="bulkBillingMethod" class="form-select">
<option value="">No change</option>
<option value="invoice">invoice</option>
<option value="internal">internal</option>
<option value="prepaid">prepaid</option>
<option value="fixed_price">fixed_price</option>
</select>
</div>
<div class="col-12 col-md-2">
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
<select id="bulkPrepaidCard" class="form-select">
<option value="">Select card</option>
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
<th>ID</th>
<th>Customer</th>
<th>Date</th>
<th>Case</th>
<th>Hours</th>
<th>Status</th>
<th>Billable</th>
<th>Method</th>
<th>Hours edit</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queueBody">
<tr>
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<style>
.time-queue { max-width: 1700px; }
.queue-summary { background:linear-gradient(135deg,#f7fbff,#fff); border:1px solid #d8e3ed; border-radius:1rem; }
.queue-stat { min-width:150px; }.queue-stat strong{font-size:1.5rem;color:#15476c}
.time-group { border:1px solid #dce5ed;border-radius:.85rem;overflow:hidden }.time-group header{background:#f5f8fb;border-bottom:1px solid #dce5ed}
.time-group header { padding:.45rem .75rem!important; font-size:.85rem; }
.time-row{border-left:4px solid #0d6efd;padding:.42rem .7rem!important;min-height:43px}.time-row[data-method="prepaid"]{border-left-color:#6f42c1}.time-row[data-method="subscription"]{border-left-color:#198754}.time-row[data-method="internal"],.time-row[data-method="non_billable"]{border-left-color:#6c757d}.time-row+.time-row{border-top:1px solid #edf1f4}
.time-row .row { display:grid;grid-template-columns:22px minmax(290px,2.4fr) minmax(125px,.85fr) minmax(94px,.55fr) minmax(170px,.9fr) 82px;gap:.5rem;align-items:center;margin:0; }
.time-row .row>[class*="col"] { width:auto;max-width:none;flex:none;padding:0;min-width:0; }
.time-row .col-12.col-lg-4>div { display:inline; }.time-row .col-12.col-lg-4>div+div:before { content:' · ';color:#94a3b8; }
.time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; }
.time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; }
.time-row .col-12.col-lg-4,.time-row .col-6,.time-row .col-8 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; }
.time-row > .mt-2 { display:none; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; }
@media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}}
@media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}}
.queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem}
.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#6c757d;margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}}
</style>
<main class="container-fluid py-4 time-queue">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div id="messageArea" aria-live="polite"></div>
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries"></strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours"></strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice"></strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Intet afregnes uden forhåndsvisning.</div></div></section>
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Status</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-4"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Kontrollér beløb og fejl før afregningen gennemføres.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></section>
<div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div>
<section id="queueContent" aria-live="polite"></section>
<div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div>
</main>
<script>
(() => {
const state = {
items: [],
selected: new Set(),
loading: false,
};
const queueBody = document.getElementById('queueBody');
const selectAll = document.getElementById('selectAll');
const selectedCountBadge = document.getElementById('selectedCountBadge');
const filterCustomer = document.getElementById('filterCustomer');
const filterStatus = document.getElementById('filterStatus');
const filterBillable = document.getElementById('filterBillable');
const filterQuery = document.getElementById('filterQuery');
const bulkDescription = document.getElementById('bulkDescription');
const bulkHours = document.getElementById('bulkHours');
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
function selectedIds() {
return Array.from(state.selected);
}
function renderRows() {
if (!state.items.length) {
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
return;
}
queueBody.innerHTML = state.items.map((item) => {
const id = Number(item.id);
const checked = state.selected.has(id) ? 'checked' : '';
const date = item.worked_date || '-';
const hours = item.approved_hours || item.original_hours || 0;
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
const title = item.case_title || '-';
const desc = (item.description || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const method = item.billing_method || 'invoice';
return `
<tr>
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
<td>${id}</td>
<td>${customer}</td>
<td>${date}</td>
<td>${title}</td>
<td>${hours}</td>
<td>${item.status || '-'}</td>
<td>${item.billable === false ? 'false' : 'true'}</td>
<td>
<select class="form-select form-select-sm inline-method" data-id="${id}">
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
</select>
</td>
<td>
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
</td>
<td>
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
</td>
<td>
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
</td>
</tr>
`;
}).join('');
document.querySelectorAll('.row-check').forEach((cb) => {
cb.addEventListener('change', (e) => {
const id = Number(e.target.dataset.id);
if (e.target.checked) state.selected.add(id);
else state.selected.delete(id);
syncSelectAll();
});
});
document.querySelectorAll('.inline-save').forEach((btn) => {
btn.addEventListener('click', async (e) => {
const id = Number(e.target.dataset.id);
await saveInlineRow(id, e.target);
});
});
syncSelectAll();
}
function syncSelectAll() {
const ids = state.items.map((x) => Number(x.id));
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
selectAll.checked = Boolean(allSelected);
selectedCountBadge.textContent = `${state.selected.size} selected`;
}
async function api(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
function buildListUrl() {
const params = new URLSearchParams();
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
if (filterStatus.value) params.set('status', filterStatus.value);
if (filterBillable.value) params.set('billable', filterBillable.value);
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
params.set('limit', '500');
return `/api/v1/economy/time-queue?${params.toString()}`;
}
async function loadEntries() {
if (state.loading) return;
state.loading = true;
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
try {
const data = await api(buildListUrl());
state.items = data.items || [];
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
renderRows();
} catch (err) {
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
} finally {
state.loading = false;
}
}
async function loadPrepaidCards() {
try {
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
const opts = ['<option value="">Select card</option>'];
(data.items || []).forEach((card) => {
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
opts.push(`<option value="${card.id}">${label}</option>`);
});
bulkPrepaidCard.innerHTML = opts.join('');
} catch (_) {
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
}
}
async function loadCustomers() {
try {
const data = await api('/api/v1/economy/time-queue/customers');
const current = filterCustomer.value;
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
(data.items || []).forEach((row) => {
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
opts.push(`<option value="${row.customer_id}">${label}</option>`);
});
filterCustomer.innerHTML = opts.join('');
if (current) {
filterCustomer.value = current;
}
} catch (_) {
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
}
}
async function clearFilters() {
filterCustomer.value = '';
filterStatus.value = '';
filterBillable.value = '';
filterQuery.value = '';
setActiveQuickFilter(null);
await loadEntries();
}
function setActiveQuickFilter(active) {
quickFilterBtns.forEach((btn) => {
const isActive = btn.dataset.filter === active;
btn.classList.toggle('btn-primary', isActive);
btn.classList.toggle('btn-outline-primary', !isActive);
});
}
async function applyQuickFilter(type) {
if (type === 'pending') {
filterStatus.value = 'pending';
filterBillable.value = '';
} else if (type === 'billable') {
filterStatus.value = '';
filterBillable.value = 'true';
} else if (type === 'ready') {
filterStatus.value = 'approved';
filterBillable.value = 'true';
}
setActiveQuickFilter(type);
await loadEntries();
}
async function saveInlineRow(id, buttonEl) {
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
if (!hoursInput || !descInput || !methodSelect) return;
const originalHours = Number(hoursInput.value);
const description = (descInput.value || '').trim();
const billingMethod = methodSelect.value;
if (!originalHours || originalHours <= 0) {
alert('Hours must be greater than 0');
return;
}
const prevText = buttonEl.textContent;
buttonEl.disabled = true;
buttonEl.textContent = 'Gemmer...';
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify({
ids: [id],
original_hours: originalHours,
description,
billing_method: billingMethod,
}),
});
await loadEntries();
await loadCustomers();
} catch (err) {
alert(err.message);
} finally {
buttonEl.disabled = false;
buttonEl.textContent = prevText;
}
}
async function doBulkUpdate() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
if (!payload.description && !payload.original_hours && !payload.billing_method) {
return alert('Set at least one update field');
}
try {
await api('/api/v1/economy/time-queue/bulk-update', {
method: 'PATCH',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkApprove() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const payload = { ids };
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
try {
await api('/api/v1/economy/time-queue/bulk-approve', {
method: 'POST',
body: JSON.stringify(payload),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doBulkDelete() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
if (reason === null) return;
try {
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
method: 'POST',
body: JSON.stringify({ ids, reason }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doApplyPrepaid() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
try {
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
method: 'POST',
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
});
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
async function doSendInvoices() {
const ids = selectedIds();
if (!ids.length) return alert('Select at least one entry');
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overførsel til e-conomic)');
if (!ok) return;
try {
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
method: 'POST',
body: JSON.stringify({ ids }),
});
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
const draftId = x.draft_id || x.order_id;
return `customer ${x.customer_id}, draft ${draftId}`;
}).join('\n');
const skipped = (result.skipped_missing_customer || []);
const failedCustomers = (result.failed_customers || []);
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
const nextStep = result.orders_url ? `\n\nÅbn ordre: ${result.orders_url}` : '';
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
const failedMsg = failedCustomers.length
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
: '';
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
await loadCustomers();
await loadEntries();
} catch (err) {
alert(err.message);
}
}
selectAll.addEventListener('change', () => {
if (selectAll.checked) {
state.items.forEach((item) => state.selected.add(Number(item.id)));
} else {
state.items.forEach((item) => state.selected.delete(Number(item.id)));
}
renderRows();
});
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
quickFilterBtns.forEach((btn) => {
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
});
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
el.addEventListener('change', loadEntries);
});
filterQuery.addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadEntries();
});
loadCustomers();
loadPrepaidCards();
loadEntries();
const state={items:[],selected:new Set(),preview:null,loading:false,cards:[]}; const $=id=>document.getElementById(id);
const labels={pending:'Afventer',approved:'Godkendt',rejected:'Afvist',invoice:'Faktura',prepaid:'Klippekort',subscription:'Abonnement / fast pris',internal:'Intern tid',non_billable:'Ikke-fakturerbar tid'};
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2});
const norm=v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[String(v||'invoice').toLowerCase()]||String(v||'invoice').toLowerCase(); const ids=()=>[...state.selected];
function message(type,text){$('messageArea').innerHTML=`<div class="alert alert-${type} alert-dismissible fade show">${esc(text)}<button class="btn-close" data-bs-dismiss="alert"></button></div>`}
async function api(url,options={}){const r=await fetch(url,{headers:{'Content-Type':'application/json'},...options});const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='object'?(d.detail.message||'Handlingen kunne ikke gennemføres'):(d.detail||'Handlingen kunne ikke gennemføres'));e.payload=d;throw e}return d}
function payload(){const method=$('settlementMethod').value,card=Number($('prepaidCard').value),agreement=Number($('agreementId').value);return {ids:ids(),...(method?{billing_method:method}:{}),...(card?{prepaid_card_id:card}:{}),...(agreement?{fixed_price_agreement_id:agreement}:{})}}
function status(v){return `<span class="badge ${v==='approved'?'text-bg-success':v==='rejected'?'text-bg-danger':'text-bg-warning'}">${esc(labels[v]||v||'Ukendt')}</span>`} function method(v){const m=norm(v),cls=m==='invoice'?'text-bg-primary':m==='prepaid'?'text-bg-purple':m==='subscription'?'text-bg-success':'text-bg-secondary';return `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`}
function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)}
function selectedRows(){return state.items.filter(x=>state.selected.has(Number(x.id)))}
function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]}
function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)}
function actualHours(rows){return rows.reduce((total,x)=>total+Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)/60,0)}
function cardHours(rows,block){const safeBlock=Math.max(1,Number(block)||30);return rows.reduce((total,x)=>{const minutes=Math.max(0,Math.round(Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)));return total+(minutes?Math.ceil(minutes/safeBlock)*safeBlock/60:0)},0)}
function renderPrepaidHelp(){const selected=selectedRows(),customers=selectedCardCustomerIds(),card=state.cards.find(x=>Number(x.id)===Number($('prepaidCard').value)),storedHours=selectedHours();if(!selected.length){$('prepaidHelp').textContent='Vælg tidsregistreringer for at se kundens klippekort.';return}if(customers.length!==1){$('prepaidHelp').innerHTML='<span class="text-danger">Vælg kun tider fra ét firma for at bruge ét klippekort.</span>';return}const company=selected[0].customer_name||'den valgte kunde';if(!card){$('prepaidHelp').textContent=`${esc(company)} · Fakturerbar tid: ${h(storedHours)} timer. Vælg et klippekort for at se kortets afrunding.`;return}const block=Number(card.rounding_minutes)||30,actual=actualHours(selected),hours=cardHours(selected,block),remaining=Number(card.remaining_hours||0),after=remaining-hours;$('prepaidHelp').innerHTML=`${esc(company)} · Faktisk tid: <strong>${h(actual)} t</strong> → klippes som <strong>${h(hours)} t</strong> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-link customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg kunde</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{e.dataset.ids.split(',').forEach(id=>state.selected.add(Number(id)));selectionChanged()}))}
function url(){const p=new URLSearchParams({limit:'500'});if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`}
async function loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${Number(x.customer_id)}">${esc(x.customer_name)} (${Number(x.open_count||0)})</option>`).join('')}`;$('filterCustomer').value=v}catch(_){message('warning','Firmafilteret kunne ikke hentes.') }}
async function loadCards(){const select=$('prepaidCard'),customers=selectedCardCustomerIds(),current=Number(select.value);state.cards=[];select.disabled=true;if(customers.length!==1){select.innerHTML=`<option value="">${customers.length>1?'Vælg tider fra ét firma':'Vælg først tider fra ét firma'}</option>`;renderPrepaidHelp();return}try{const d=await api(`/api/v1/economy/time-queue/prepaid-cards?customer_id=${customers[0]}`);state.cards=(d.items||[]).filter(x=>x.status==='active'&&Number(x.remaining_hours)>0);select.innerHTML=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${Number(x.id)}">${esc(x.card_number)} · ${h(x.remaining_hours)} timer tilbage</option>`).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';try{const d=await api(url());state.items=d.items||[];state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`}finally{state.loading=false}}
async function saveMethod(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');const m=$('settlementMethod').value;if(!m)return message('warning','Vælg en afregningstype, der skal gemmes.');try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify({ids:ids(),billing_method:m,billable:['invoice','prepaid'].includes(m)})});message('success','Afregningstypen er gemt.');await load()}catch(e){message('danger',e.message)}}
async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v3`;$('caseDetailBody').innerHTML='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}}
function previewView(d){state.preview=d;$('previewPanel').classList.remove('d-none');const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[];$('previewBody').innerHTML=`${errors.length?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider er klar til afregning.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladden.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden fakturalinje.</div></div></div>`).join('')}</div>`;$('settleBtn').disabled=!d.valid;$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'})}
async function preview(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');try{previewView(await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(payload())}))}catch(e){message('danger',e.message)}}
async function settle(){if(!state.preview?.valid)return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Afregner…';try{const d=await api('/api/v1/economy/time-queue/settle',{method:'POST',body:JSON.stringify(payload())});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();$('previewPanel').classList.add('d-none');if(d.orders_url)$('messageArea').insertAdjacentHTML('beforeend',`<p><a class="btn btn-outline-primary" href="${esc(d.orders_url)}">Åbn ordrekladde</a></p>`);await loadCustomers();await load()}catch(e){message('danger',e.message);if(e.payload?.detail?.preview)previewView(e.payload.detail.preview)}finally{$('settleBtn').disabled=false;$('settleBtn').innerHTML='<i class="bi bi-check2-circle me-1"></i>Gennemfør afregning'}}
function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()}
$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load();
})();
</script>
{% endblock %}

View File

@ -1536,13 +1536,13 @@ async def delete_todo_step(step_id: int):
raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)])
async def update_sag(sag_id: int, updates: dict = Body(...)):
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case."""
try:
# Check if case exists
check = execute_query(
"""
SELECT id, status, template_key, priority, ansvarlig_bruger_id, assigned_group_id
SELECT *
FROM sag_sager
WHERE id = %s AND deleted_at IS NULL
""",
@ -1674,6 +1674,18 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
}
change_messages: List[str] = []
changed_by = "System"
try:
acting_user_id = _get_user_id_from_request(request)
acting_user = execute_query_single(
"""SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''),
CONCAT('Bruger #', user_id::text)) AS name
FROM users WHERE user_id = %s""",
(acting_user_id,),
)
changed_by = str((acting_user or {}).get("name") or f"Bruger #{acting_user_id}")
except Exception:
pass
if "status" in updates:
old_value = _norm(previous_row.get("status")) or "-"
@ -1715,13 +1727,29 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
if old_group != new_group:
change_messages.append(f"👥 Gruppe ændret: {old_group}{new_group}")
explicitly_logged = {"status", "template_key", "type", "priority", "ansvarlig_bruger_id", "assigned_group_id"}
field_labels = {
"titel": "Titel", "beskrivelse": "Beskrivelse", "customer_id": "Kunde",
"start_date": "Startdato", "deadline": "Deadline", "deferred_until": "Udskudt til",
"deferred_until_case_id": "Afventer sag", "deferred_until_status": "Afventer status",
"supplier_flow_type": "Leverandørflow", "supplier_flow_confidence": "Leverandørflow-sikkerhed",
}
for field in allowed_fields:
if field not in updates or field in explicitly_logged:
continue
old_value = _norm(previous_row.get(field)) or "-"
new_value = _norm(updated_row.get(field)) or "-"
if old_value != new_value:
label = field_labels.get(field, field.replace("_", " ").capitalize())
change_messages.append(f"✏️ {label} ændret: {old_value}{new_value}")
for message in change_messages:
execute_query(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, "System", message, True),
(sag_id, changed_by, message, True),
)
except Exception as comment_log_error:
logger.warning("⚠️ Could not log field-change comments for case %s: %s", sag_id, comment_log_error)
@ -3146,6 +3174,125 @@ async def create_sale_item(sag_id: int, data: dict):
raise HTTPException(status_code=500, detail="Failed to create sale item")
@router.post("/sag/{sag_id}/sale-items/batch")
async def create_sale_items_batch(sag_id: int, data: dict):
"""Create purchase and/or sale lines from one selected product atomically.
A product lookup is one business action, but it can have two accounting
consequences: an expected supplier purchase and a customer sale. Keeping
both inserts in one transaction prevents half-created product flows.
"""
lines = data.get("lines") if isinstance(data, dict) else None
if not isinstance(lines, list) or not lines:
raise HTTPException(status_code=400, detail="lines must contain at least one item")
if len(lines) > 2:
raise HTTPException(status_code=400, detail="A product action can contain at most one purchase and one sale line")
try:
case_exists = execute_query_single(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not case_exists:
raise HTTPException(status_code=404, detail="Case not found")
if not _table_exists("sag_salgsvarer"):
raise HTTPException(status_code=503, detail="Varelinjer er ikke klar endnu")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
normalized_lines = []
seen_types = set()
for raw_line in lines:
if not isinstance(raw_line, dict):
raise HTTPException(status_code=400, detail="Every line must be an object")
item_type = str(raw_line.get("type") or "").strip().lower()
if item_type not in {"sale", "purchase"}:
raise HTTPException(status_code=400, detail="type must be sale or purchase")
if item_type in seen_types:
raise HTTPException(status_code=400, detail="Only one line of each type is allowed")
seen_types.add(item_type)
description = str(raw_line.get("description") or "").strip()
if not description:
raise HTTPException(status_code=400, detail="description is required")
try:
amount = float(raw_line.get("amount"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="amount must be a number")
if amount < 0:
raise HTTPException(status_code=400, detail="amount cannot be negative")
status = str(raw_line.get("status") or "draft").strip().lower()
if status not in {"draft", "confirmed", "cancelled"}:
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
purchase_purpose = _normalize_purchase_purpose(raw_line.get("purchase_purpose"), item_type)
supplier_invoice_id = supplier_invoice_line_id = None
if has_purchase_columns and item_type == "purchase":
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
raw_line.get("supplier_invoice_id"),
raw_line.get("supplier_invoice_line_id"),
)
normalized_lines.append({
"type": item_type,
"description": description,
"quantity": raw_line.get("quantity"),
"unit": raw_line.get("unit"),
"unit_price": raw_line.get("unit_price"),
"amount": amount,
"currency": str(raw_line.get("currency") or "DKK").upper(),
"status": status,
"line_date": raw_line.get("line_date"),
"external_ref": raw_line.get("external_ref"),
"product_id": raw_line.get("product_id"),
"purchase_purpose": purchase_purpose,
"supplier_invoice_id": supplier_invoice_id,
"supplier_invoice_line_id": supplier_invoice_line_id,
})
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
created = []
for line in normalized_lines:
if has_purchase_columns:
cursor.execute(
"""
INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
purchase_purpose, supplier_invoice_id, supplier_invoice_line_id)
VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s,
%(purchase_purpose)s, %(supplier_invoice_id)s, %(supplier_invoice_line_id)s)
RETURNING *
""",
{"sag_id": sag_id, **line},
)
else:
cursor.execute(
"""
INSERT INTO sag_salgsvarer
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s)
RETURNING *
""",
{"sag_id": sag_id, **line},
)
created.append(dict(cursor.fetchone()))
conn.commit()
logger.info("✅ Created %s linked product lines for case %s", len(created), sag_id)
return {"created": created}
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating product lines for case %s: %s", sag_id, e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create product lines")
@router.get("/sag/{sag_id}/sale-items/{item_id}")
async def get_sale_item(sag_id: int, item_id: int):
"""Get a single sale item for a case."""
@ -3429,6 +3576,25 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
events = []
case_details_query = f"""
SELECT s.id, s.titel, s.created_at,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.created_by_user_id
WHERE s.id IN ({placeholders})
"""
for row in execute_query(case_details_query, tuple(case_ids)) or []:
created_at = row.get("created_at")
if not created_at:
continue
events.append({
"id": f"case:{row.get('id')}:created", "event_type": "case", "event_subtype": "created",
"source": "case", "timestamp": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at),
"sag_id": row.get("id"), "sag_titel": row.get("titel"),
"forfatter": str(row.get("created_by_name") or "System"),
"title": "Sag oprettet", "description": str(row.get("titel") or ""),
})
for row in comments:
created_at = row.get("created_at")
if not created_at:
@ -3494,10 +3660,12 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
if _table_exists("sag_reminders"):
reminders_query = f"""
SELECT r.id, r.sag_id, r.title, r.message, r.event_type,
r.next_check_at, r.scheduled_at,
r.next_check_at, r.scheduled_at, r.created_at,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name,
s.titel AS source_sag_titel
FROM sag_reminders r
LEFT JOIN sag_sager s ON s.id = r.sag_id
LEFT JOIN users u ON u.user_id = r.created_by_user_id
WHERE r.deleted_at IS NULL
AND r.sag_id IN ({placeholders})
ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC
@ -3506,13 +3674,17 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
reminders = execute_query(reminders_query, tuple(case_ids)) or []
for row in reminders:
start_value = row.get("next_check_at") or row.get("scheduled_at")
start_value = row.get("created_at") or row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value)
title = str(row.get("title") or "Påmindelse")
message = str(row.get("message") or "")
scheduled_value = row.get("scheduled_at") or row.get("next_check_at")
if scheduled_value:
scheduled_text = scheduled_value.isoformat() if isinstance(scheduled_value, datetime) else str(scheduled_value)
message = f"{message}\nPlanlagt til: {scheduled_text}".strip()
event_kind = str(row.get("event_type") or "reminder")
events.append({
@ -3523,7 +3695,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": "Reminder",
"forfatter": str(row.get("created_by_name") or "System"),
"title": f"Påmindelse · {title}",
"description": message,
})
@ -3532,9 +3704,15 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
time_query = f"""
SELECT t.id, t.sag_id, t.description, t.entry_type,
t.worked_date, t.created_at, t.approved_hours, t.original_hours,
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
s.titel AS source_sag_titel
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN users employee ON employee.user_id = t.medarbejder_id
LEFT JOIN users recorder ON LOWER(recorder.username) = LOWER(t.user_name)
WHERE t.sag_id IN ({placeholders})
ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC
LIMIT 600
@ -3542,7 +3720,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
time_entries = execute_query(time_query, tuple(case_ids)) or []
for row in time_entries:
raw_ts = row.get("worked_date") or row.get("created_at")
raw_ts = row.get("created_at") or row.get("worked_date")
if not raw_ts:
continue
@ -3552,6 +3730,8 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
hours = row.get("original_hours")
hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid"
description = str(row.get("description") or "")
employee_name = str(row.get("employee_name") or "Ukendt medarbejder")
description = f"Registreret for: {employee_name}\n{description}".strip()
entry_type = str(row.get("entry_type") or "arbejde")
events.append({
@ -3562,11 +3742,70 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"timestamp": ts,
"sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"),
"forfatter": "Tid",
"forfatter": str(row.get("actor_name") or "System"),
"title": f"Tidsregistrering · {hours_text}",
"description": description,
})
if _table_exists("sag_tags"):
tag_query = f"""
SELECT t.id, t.sag_id, t.tag_navn, t.created_at, t.closed_at, s.titel AS source_sag_titel
FROM sag_tags t LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.sag_id IN ({placeholders}) LIMIT 600
"""
for row in execute_query(tag_query, tuple(case_ids)) or []:
for action, timestamp in (("tilføjet", row.get("created_at")), ("lukket", row.get("closed_at"))):
if timestamp:
events.append({
"id": f"tag:{row.get('id')}:{action}", "event_type": "tag", "event_subtype": action,
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
"sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": "System",
"title": f"Tag {action}", "description": str(row.get("tag_navn") or ""),
})
if _table_exists("sag_todo_steps"):
todo_query = f"""
SELECT t.*, s.titel AS source_sag_titel,
COALESCE(NULLIF(TRIM(uc.full_name), ''), NULLIF(TRIM(uc.username), ''), 'System') AS created_by_name,
COALESCE(NULLIF(TRIM(ud.full_name), ''), NULLIF(TRIM(ud.username), ''), 'System') AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN sag_sager s ON s.id = t.sag_id
LEFT JOIN users uc ON uc.user_id = t.created_by_user_id
LEFT JOIN users ud ON ud.user_id = t.completed_by_user_id
WHERE t.sag_id IN ({placeholders}) LIMIT 800
"""
for row in execute_query(todo_query, tuple(case_ids)) or []:
todo_events = (("oprettet", row.get("created_at"), row.get("created_by_name")),
("færdiggjort", row.get("completed_at"), row.get("completed_by_name")),
("slettet", row.get("deleted_at"), "System"))
for action, timestamp, actor in todo_events:
if timestamp:
events.append({
"id": f"todo:{row.get('id')}:{action}", "event_type": "todo", "event_subtype": action,
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
"sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": str(actor or "System"),
"title": f"Tjeklistepunkt {action}", "description": str(row.get("title") or ""),
})
if _table_exists("sag_relationer"):
relation_query = f"""
SELECT id, kilde_sag_id, målsag_id, relationstype, created_at, deleted_at
FROM sag_relationer
WHERE kilde_sag_id IN ({placeholders}) OR målsag_id IN ({placeholders}) LIMIT 600
"""
for row in execute_query(relation_query, tuple(case_ids) + tuple(case_ids)) or []:
current_id = row.get("kilde_sag_id") if row.get("kilde_sag_id") in case_ids else row.get("målsag_id")
other_id = row.get("målsag_id") if current_id == row.get("kilde_sag_id") else row.get("kilde_sag_id")
for action, timestamp in (("oprettet", row.get("created_at")), ("fjernet", row.get("deleted_at"))):
if timestamp:
events.append({
"id": f"relation:{row.get('id')}:{action}", "event_type": "relation", "event_subtype": action,
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
"sag_id": current_id, "sag_titel": next((r.get("titel") for r in case_rows if r.get("id") == current_id), None),
"forfatter": "System", "title": f"Relation {action}",
"description": f"{row.get('relationstype') or 'Relateret til'} · Sag #{other_id}",
})
def _sort_key(item: Dict) -> datetime:
raw = str(item.get("timestamp") or "")
try:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
"""Shipmondo shipping integration."""

View File

@ -0,0 +1 @@
"""Shipmondo backend package."""

View File

@ -0,0 +1,75 @@
import json
import logging
from typing import Any, Dict, List, Optional
import aiohttp
from app.core.config import settings
logger = logging.getLogger(__name__)
class ShipmondoApiClient:
def __init__(self) -> None:
self.base_url = (settings.SHIPMONDO_API_BASE_URL or "").rstrip("/")
self.timeout_seconds = max(5, int(settings.SHIPMONDO_TIMEOUT_SECONDS or 30))
@property
def configured(self) -> bool:
return bool(self.base_url and settings.SHIPMONDO_API_USER and settings.SHIPMONDO_API_KEY)
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_payload: Optional[Dict[str, Any]] = None,
) -> Any:
if not self.configured:
raise RuntimeError("Shipmondo API credentials are not configured")
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
auth = aiohttp.BasicAuth(settings.SHIPMONDO_API_USER, settings.SHIPMONDO_API_KEY)
headers = {"Accept": "application/json", "Content-Type": "application/json"}
url = f"{self.base_url}/{path.lstrip('/')}"
async with aiohttp.ClientSession(timeout=timeout, auth=auth, headers=headers) as session:
async with session.request(method, url, params=params, json=json_payload) as response:
body = await response.text()
if response.status >= 400:
detail = ""
try:
parsed = json.loads(body)
detail = str(parsed.get("message") or parsed.get("error") or parsed.get("errors") or "")
except (ValueError, AttributeError):
detail = body.strip()
detail = detail[:300]
logger.warning("Shipmondo request failed (%s %s): HTTP %s", method, path, response.status)
suffix = f": {detail}" if detail else ""
raise RuntimeError(f"Shipmondo API returned HTTP {response.status}{suffix}")
if not body.strip():
return {}
return json.loads(body)
async def list_products(self, country_code: str) -> List[Dict[str, Any]]:
payload = await self._request("GET", "/products", params={"country_code": country_code.upper()})
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
raw_items = payload.get("items") or payload.get("products") or payload.get("data") or []
if isinstance(raw_items, list):
return [item for item in raw_items if isinstance(item, dict)]
return []
async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
response = await self._request("POST", "/shipments", json_payload=payload)
if not isinstance(response, dict):
raise RuntimeError("Shipmondo returned an invalid shipment response")
return response
async def get_shipment(self, shipment_id: str) -> Dict[str, Any]:
response = await self._request("GET", f"/shipments/{shipment_id}")
if not isinstance(response, dict):
raise RuntimeError("Shipmondo returned an invalid shipment response")
return response

View File

@ -0,0 +1,66 @@
from typing import Optional
from fastapi import APIRouter, Query, Request, Response
from app.modules.shipmondo.backend.service import shipmondo_service
from app.modules.shipmondo.models.schemas import (
ShipmondoBookingCreate,
ShipmondoBookingListResponse,
ShipmondoBookingResponse,
ShipmondoBookingSubmitResponse,
ShipmondoProductListResponse,
)
router = APIRouter()
def _user_id_from_request(request: Request) -> Optional[int]:
raw_user_id = getattr(request.state, "user_id", None)
try:
return int(raw_user_id) if raw_user_id is not None else None
except (TypeError, ValueError):
return None
@router.get("/shipmondo/config")
async def shipmondo_config() -> dict:
return {
"enabled": shipmondo_service.enabled,
"configured": shipmondo_service.configured,
"read_only": shipmondo_service.read_only,
"dry_run": shipmondo_service.dry_run,
}
@router.get("/shipmondo/products", response_model=ShipmondoProductListResponse)
async def list_products(country_code: str = Query(default="DK", min_length=2, max_length=2)):
return {"items": await shipmondo_service.list_products(country_code)}
@router.post("/shipmondo/bookings", response_model=ShipmondoBookingResponse)
async def create_booking(payload: ShipmondoBookingCreate, request: Request):
return shipmondo_service.create_booking_draft(payload, _user_id_from_request(request))
@router.get("/shipmondo/bookings", response_model=ShipmondoBookingListResponse)
async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)):
return {"items": shipmondo_service.list_bookings(case_id)}
@router.get("/shipmondo/bookings/{booking_ref}", response_model=ShipmondoBookingResponse)
async def get_booking(booking_ref: str):
return shipmondo_service.get_booking(booking_ref)
@router.post("/shipmondo/bookings/{booking_ref}/submit", response_model=ShipmondoBookingSubmitResponse)
async def submit_booking(booking_ref: str, request: Request):
return await shipmondo_service.submit_booking(booking_ref, _user_id_from_request(request))
@router.get("/shipmondo/bookings/{booking_ref}/label")
async def download_label(booking_ref: str):
return Response(
content=shipmondo_service.get_label_pdf(booking_ref),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{booking_ref}.pdf"'},
)

View File

@ -0,0 +1,388 @@
import base64
import json
import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional
from uuid import uuid4
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.modules.shipmondo.backend.api_client import ShipmondoApiClient
from app.modules.shipmondo.models.schemas import ShipmondoBookingCreate
logger = logging.getLogger(__name__)
def _json_default(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, default=_json_default)
def _json_object(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except ValueError:
return {}
return {}
def _json_list(value: Any) -> List[Any]:
if isinstance(value, list):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except ValueError:
return []
return []
def _to_float(value: Any) -> Optional[float]:
try:
if value is None or value == "":
return None
return float(value)
except (TypeError, ValueError):
return None
def extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]:
for key in ("pkg_no", "tracking_number", "trackingNumber", "external_pkg_no"):
value = payload.get(key)
if value:
return str(value).strip()
for parcel in payload.get("parcels") or []:
if not isinstance(parcel, dict):
continue
value = parcel.get("pkg_no") or parcel.get("tracking_number")
if value:
return str(value).strip()
values = parcel.get("pkg_nos")
if isinstance(values, list) and values:
return str(values[0]).strip()
return None
def extract_tracking_url(payload: Dict[str, Any]) -> Optional[str]:
for key in ("tracking_url", "trackingUrl", "carrier_tracking_url"):
value = payload.get(key)
if isinstance(value, str) and value.startswith(("https://", "http://")):
return value
return None
def extract_price(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]:
amount = _to_float(payload.get("price") or payload.get("total_amount") or payload.get("amount"))
currency = payload.get("currency") or payload.get("currency_code")
return amount, (str(currency).upper() if currency else ("DKK" if amount is not None else None))
def extract_label_base64(payload: Dict[str, Any]) -> Optional[str]:
for key in ("label_base64", "labelBase64", "label"):
value = payload.get(key)
if isinstance(value, str) and value.strip() and not value.startswith(("http://", "https://")):
return value.split(",", 1)[-1].strip()
for parcel in payload.get("parcels") or []:
if isinstance(parcel, dict):
value = parcel.get("label_base64") or parcel.get("labelBase64")
if isinstance(value, str) and value.strip():
return value.split(",", 1)[-1].strip()
return None
def build_shipmondo_payload(shipment: Dict[str, Any]) -> Dict[str, Any]:
sender = {
"type": "sender",
"name": settings.SHIPMONDO_SENDER_NAME.strip(),
"attention": settings.SHIPMONDO_SENDER_ATTENTION.strip() or None,
"address1": settings.SHIPMONDO_SENDER_ADDRESS1.strip(),
"address2": settings.SHIPMONDO_SENDER_ADDRESS2.strip() or None,
"postal_code": settings.SHIPMONDO_SENDER_POSTAL_CODE.strip(),
"city": settings.SHIPMONDO_SENDER_CITY.strip(),
"country_code": (settings.SHIPMONDO_SENDER_COUNTRY_CODE or "DK").strip().upper(),
"email": settings.SHIPMONDO_SENDER_EMAIL.strip() or None,
"phone": settings.SHIPMONDO_SENDER_PHONE.strip() or None,
}
receiver = {
"type": "receiver",
"name": shipment.get("company_name") or shipment["recipient_name"],
"attention": shipment["recipient_name"] if shipment.get("company_name") else None,
"address1": shipment["address_line1"],
"address2": shipment.get("address_line2"),
"postal_code": shipment["postal_code"],
"city": shipment["city"],
"country_code": shipment["country_code"],
"email": shipment.get("email"),
"phone": shipment.get("phone"),
}
parcels = []
for index, parcel in enumerate(shipment.get("parcels") or [], start=1):
item: Dict[str, Any] = {
"quantity": 1,
"weight": max(1, round(float(parcel["weight_kg"]) * 1000)),
"content": parcel.get("description"),
"internal_reference": f"{shipment['booking_ref']}-{index}",
}
for source, target in (("length_cm", "length"), ("width_cm", "width"), ("height_cm", "height")):
if parcel.get(source) is not None:
item[target] = max(1, round(float(parcel[source])))
parcels.append(item)
return {
"own_agreement": bool(shipment.get("own_agreement")),
"label_format": "10x19_pdf",
"print": False,
"product_code": shipment["product_code"],
"service_codes": ",".join(_json_list(shipment.get("service_codes"))),
"automatic_select_service_point": bool(shipment.get("automatic_select_service_point")),
"reference": f"Sag #{shipment['case_id']} · {shipment['booking_ref']}",
"parties": [sender, receiver],
"parcels": parcels,
}
class ShipmondoService:
def __init__(self) -> None:
self.client = ShipmondoApiClient()
@property
def enabled(self) -> bool:
return bool(settings.SHIPMONDO_ENABLED)
@property
def read_only(self) -> bool:
return bool(settings.SHIPMONDO_READ_ONLY)
@property
def dry_run(self) -> bool:
return bool(settings.SHIPMONDO_DRY_RUN)
@property
def configured(self) -> bool:
return self.client.configured
def _assert_enabled(self) -> None:
if not self.enabled:
raise HTTPException(status_code=503, detail="Shipmondo-integrationen er slået fra")
def _assert_sender_configured(self) -> None:
required = {
"navn": settings.SHIPMONDO_SENDER_NAME,
"adresse": settings.SHIPMONDO_SENDER_ADDRESS1,
"postnummer": settings.SHIPMONDO_SENDER_POSTAL_CODE,
"by": settings.SHIPMONDO_SENDER_CITY,
}
missing = [label for label, value in required.items() if not str(value or "").strip()]
if missing:
raise HTTPException(
status_code=503,
detail=f"Shipmondo mangler afsenderens {', '.join(missing)} i konfigurationen",
)
def _booking_ref(self) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return f"SMD-{stamp}-{uuid4().hex[:8].upper()}"
def _validate_relations(self, payload: ShipmondoBookingCreate) -> None:
if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
if payload.customer_id and not execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,)):
raise HTTPException(status_code=404, detail="Kunden findes ikke")
if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)):
raise HTTPException(status_code=404, detail="Kontakten findes ikke")
def _fetch_parcels(self, shipment_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT weight_kg, length_cm, width_cm, height_cm, description
FROM shipmondo_shipment_parcels
WHERE shipment_id = %s
ORDER BY id ASC
""",
(shipment_id,),
) or []
return [dict(row) for row in rows]
def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
mapped = dict(row)
mapped["service_codes"] = [str(value) for value in _json_list(mapped.get("service_codes"))]
mapped["parcels"] = self._fetch_parcels(int(mapped["id"]))
api_response = _json_object(mapped.get("api_response"))
mapped["tracking_number"] = mapped.get("tracking_number") or extract_tracking_number(api_response)
mapped["tracking_url"] = mapped.get("tracking_url") or extract_tracking_url(api_response)
if extract_label_base64(api_response):
mapped["label_url"] = f"/api/v1/shipmondo/bookings/{mapped['booking_ref']}/label"
else:
label_url = api_response.get("label_url")
mapped["label_url"] = label_url if isinstance(label_url, str) else None
return mapped
async def list_products(self, country_code: str) -> List[Dict[str, Any]]:
self._assert_enabled()
if not self.configured:
raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret")
try:
products = await self.client.list_products(country_code)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return [product for product in products if product.get("available", True)]
def create_booking_draft(self, payload: ShipmondoBookingCreate, user_id: Optional[int]) -> Dict[str, Any]:
self._assert_enabled()
self._validate_relations(payload)
booking_ref = self._booking_ref()
row = execute_query_single(
"""
INSERT INTO shipmondo_shipments (
booking_ref, case_id, customer_id, contact_id, product_code, product_name,
carrier_code, carrier_name, service_codes, shipment_status, own_agreement,
automatic_select_service_point, recipient_name, company_name, address_line1,
address_line2, postal_code, city, country_code, phone, email, dry_run,
created_by_user_id, updated_by_user_id, api_payload, api_response
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 'draft', %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb
) RETURNING *
""",
(
booking_ref, payload.case_id, payload.customer_id, payload.contact_id,
payload.product_code, payload.product_name, payload.carrier_code, payload.carrier_name,
_json_dumps(payload.service_codes), payload.own_agreement,
payload.automatic_select_service_point, payload.address.recipient_name,
payload.address.company_name, payload.address.address_line1, payload.address.address_line2,
payload.address.postal_code, payload.address.city, payload.address.country_code,
payload.address.phone, payload.address.email, self.dry_run, user_id, user_id,
_json_dumps(payload.model_dump(mode="json")), _json_dumps({"status": "draft_created"}),
),
)
if not row:
raise HTTPException(status_code=500, detail="Shipmondo-draft kunne ikke oprettes")
for parcel in payload.parcels:
execute_query(
"""
INSERT INTO shipmondo_shipment_parcels
(shipment_id, weight_kg, length_cm, width_cm, height_cm, description)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(row["id"], parcel.weight_kg, parcel.length_cm, parcel.width_cm, parcel.height_cm, parcel.description),
)
return self.get_booking(booking_ref)
def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]:
params: List[Any] = []
where = "WHERE deleted_at IS NULL"
if case_id is not None:
where += " AND case_id = %s"
params.append(case_id)
rows = execute_query(
f"SELECT * FROM shipmondo_shipments {where} ORDER BY created_at DESC LIMIT 200",
tuple(params),
) or []
return [self._row_to_dict(dict(row)) for row in rows]
def get_booking(self, booking_ref: str) -> Dict[str, Any]:
row = execute_query_single(
"SELECT * FROM shipmondo_shipments WHERE booking_ref = %s AND deleted_at IS NULL",
(booking_ref,),
)
if not row:
raise HTTPException(status_code=404, detail="Shipmondo-forsendelsen findes ikke")
return self._row_to_dict(dict(row))
async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]:
self._assert_enabled()
shipment = self.get_booking(booking_ref)
if shipment["shipment_status"] not in {"draft", "failed"}:
raise HTTPException(status_code=409, detail="Kun drafts og fejlede bookinger kan sendes igen")
if self.read_only:
raise HTTPException(status_code=403, detail="Shipmondo er i skrivebeskyttet tilstand")
self._assert_sender_configured()
payload = build_shipmondo_payload(shipment)
if self.dry_run:
api_response = {
"id": f"dry-{uuid4().hex[:10]}",
"pkg_no": f"SMD-DRY-{uuid4().hex[:10].upper()}",
"price": "69.00",
"currency": "DKK",
"dry_run": True,
}
new_status = "submitted"
else:
if not self.configured:
raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret")
try:
api_response = await self.client.create_shipment(payload)
except Exception as exc:
execute_query(
"""
UPDATE shipmondo_shipments
SET shipment_status = 'failed', api_response = %s::jsonb, updated_by_user_id = %s
WHERE booking_ref = %s
""",
(_json_dumps({"error": str(exc)}), user_id, booking_ref),
)
raise HTTPException(status_code=502, detail=f"Shipmondo-booking fejlede: {exc}") from exc
new_status = "booked"
tracking_number = extract_tracking_number(api_response)
tracking_url = extract_tracking_url(api_response)
total_amount, currency = extract_price(api_response)
shipmondo_id = api_response.get("id")
execute_query(
"""
UPDATE shipmondo_shipments
SET shipmondo_id = %s, shipment_status = %s, tracking_number = %s, tracking_url = %s,
total_amount = %s, currency = %s, submitted_at = CURRENT_TIMESTAMP,
api_payload = %s::jsonb, api_response = %s::jsonb, updated_by_user_id = %s
WHERE booking_ref = %s
""",
(
str(shipmondo_id) if shipmondo_id is not None else None, new_status,
tracking_number, tracking_url, total_amount, currency,
_json_dumps(payload), _json_dumps(api_response), user_id, booking_ref,
),
)
booking = self.get_booking(booking_ref)
return {
"booking_ref": booking_ref,
"status": new_status,
"dry_run": self.dry_run,
"tracking_number": tracking_number,
"tracking_url": tracking_url,
"label_url": booking.get("label_url"),
"total_amount": total_amount,
"currency": currency,
}
def get_label_pdf(self, booking_ref: str) -> bytes:
booking = self.get_booking(booking_ref)
row = execute_query_single(
"SELECT api_response FROM shipmondo_shipments WHERE id = %s",
(booking["id"],),
)
encoded = extract_label_base64(_json_object(row.get("api_response") if row else None))
if not encoded:
raise HTTPException(status_code=404, detail="Forsendelsen har endnu ingen PDF-label")
try:
return base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=500, detail="Shipmondo-labelen kunne ikke læses") from exc
shipmondo_service = ShipmondoService()

View File

@ -0,0 +1 @@
"""Shipmondo API models."""

View File

@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, field_validator
ShipmentStatus = Literal[
"draft",
"submitted",
"booked",
"in_transit",
"delivered",
"cancelled",
"failed",
]
class ShipmondoAddress(BaseModel):
recipient_name: str = Field(min_length=2, max_length=150)
company_name: Optional[str] = Field(default=None, max_length=150)
address_line1: str = Field(min_length=2, max_length=200)
address_line2: Optional[str] = Field(default=None, max_length=200)
postal_code: str = Field(min_length=2, max_length=20)
city: str = Field(min_length=2, max_length=120)
country_code: str = Field(min_length=2, max_length=2)
phone: Optional[str] = Field(default=None, max_length=50)
email: Optional[str] = Field(default=None, max_length=150)
@field_validator("country_code")
@classmethod
def normalize_country_code(cls, value: str) -> str:
return value.strip().upper()
class ShipmondoParcelInput(BaseModel):
weight_kg: float = Field(gt=0, le=2000)
length_cm: Optional[float] = Field(default=None, gt=0, le=400)
width_cm: Optional[float] = Field(default=None, gt=0, le=400)
height_cm: Optional[float] = Field(default=None, gt=0, le=400)
description: str = Field(min_length=1, max_length=255)
class ShipmondoBookingCreate(BaseModel):
case_id: int = Field(gt=0)
customer_id: Optional[int] = Field(default=None, gt=0)
contact_id: Optional[int] = Field(default=None, gt=0)
product_code: str = Field(min_length=1, max_length=80)
product_name: Optional[str] = Field(default=None, max_length=180)
carrier_code: Optional[str] = Field(default=None, max_length=80)
carrier_name: Optional[str] = Field(default=None, max_length=120)
service_codes: List[str] = Field(default_factory=list, max_length=30)
own_agreement: bool = False
automatic_select_service_point: bool = False
address: ShipmondoAddress
parcels: List[ShipmondoParcelInput] = Field(min_length=1, max_length=30)
@field_validator("service_codes")
@classmethod
def normalize_service_codes(cls, values: List[str]) -> List[str]:
return list(dict.fromkeys(value.strip().upper() for value in values if value.strip()))
class ShipmondoBookingResponse(BaseModel):
id: int
booking_ref: str
case_id: int
customer_id: Optional[int] = None
contact_id: Optional[int] = None
product_code: str
product_name: Optional[str] = None
carrier_code: Optional[str] = None
carrier_name: Optional[str] = None
service_codes: List[str] = Field(default_factory=list)
shipment_status: ShipmentStatus
recipient_name: str
city: str
country_code: str
tracking_number: Optional[str] = None
tracking_url: Optional[str] = None
label_url: Optional[str] = None
total_amount: Optional[float] = None
currency: Optional[str] = None
dry_run: bool
submitted_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
parcels: List[ShipmondoParcelInput] = Field(default_factory=list)
class ShipmondoBookingListResponse(BaseModel):
items: List[ShipmondoBookingResponse]
class ShipmondoBookingSubmitResponse(BaseModel):
booking_ref: str
status: ShipmentStatus
dry_run: bool
tracking_number: Optional[str] = None
tracking_url: Optional[str] = None
label_url: Optional[str] = None
total_amount: Optional[float] = None
currency: Optional[str] = None
class ShipmondoProductListResponse(BaseModel):
items: List[Dict[str, Any]]

View File

@ -324,11 +324,120 @@ def _calculate_sales_price_with_margin(base_price: Any, margin_percent: Optional
return round(price, 2)
def _first_apigw_value(product: Dict[str, Any], *keys: str) -> Any:
for key in keys:
value = product.get(key)
if value is not None and (not isinstance(value, str) or value.strip()):
return value
return None
def _unwrap_apigw_product(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Accept both the Hub wrapper and common API Gateway item wrappers."""
product: Any = payload
identity_keys = (
"product_name",
"name",
"productName",
"title",
"sku",
"supplier_sku",
"item_number",
"part_number",
"ean",
"gtin",
"barcode",
)
for _ in range(4):
if isinstance(product, str):
try:
product = json.loads(product)
except (TypeError, ValueError):
break
if not isinstance(product, dict):
break
if any(_first_apigw_value(product, key) is not None for key in identity_keys):
break
nested = next(
(
product.get(key)
for key in ("product", "item", "result", "data")
if isinstance(product.get(key), (dict, str))
),
None,
)
if nested is None:
break
product = nested
return dict(product) if isinstance(product, dict) else {}
def _normalize_apigw_product(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize supplier variants before rendering or importing a product."""
product = _unwrap_apigw_product(payload)
normalized = dict(product)
supplier = product.get("supplier") if isinstance(product.get("supplier"), dict) else {}
primary_category = product.get("primary_category") if isinstance(product.get("primary_category"), dict) else {}
aliases = {
"product_name": _first_apigw_value(
product,
"product_name",
"name",
"productName",
"title",
"description",
"short_description",
),
"sku": _first_apigw_value(product, "sku", "supplier_sku", "item_number", "part_number"),
"ean": _first_apigw_value(product, "ean", "ean13", "gtin", "barcode"),
"price": _first_apigw_value(product, "price", "supplier_price", "net_price", "cost_price", "unit_price"),
"currency": _first_apigw_value(product, "currency", "supplier_currency"),
"stock_qty": _first_apigw_value(product, "stock_qty", "stock", "quantity", "available_stock"),
"category": (
_first_apigw_value(product, "category", "legacy_category")
or primary_category.get("name")
),
"supplier_name": _first_apigw_value(product, "supplier_name") or supplier.get("name"),
"supplier_code": _first_apigw_value(product, "supplier_code") or supplier.get("code"),
"product_url": _first_apigw_value(
product,
"product_url",
"product_link",
"supplier_product_url",
"supplier_url",
"url",
),
}
for key, value in aliases.items():
if value is not None and (not isinstance(value, str) or value.strip()):
normalized[key] = value.strip() if isinstance(value, str) else value
if not str(normalized.get("product_name") or "").strip():
sku = str(normalized.get("sku") or normalized.get("ean") or "").strip()
descriptor = str(
normalized.get("manufacturer")
or normalized.get("supplier_name")
or "Produkt"
).strip()
if sku:
normalized["product_name"] = f"{descriptor} · {sku}"
return normalized
def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optional[int] = None) -> Dict[str, Any]:
product = payload.get("product") or payload
name = (product.get("product_name") or product.get("name") or "").strip()
product = _normalize_apigw_product(payload)
name = str(product.get("product_name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="product_name is required")
logger.warning("APIGW product import rejected because no usable name or item code was present; keys=%s", sorted(product.keys()))
raise HTTPException(
status_code=400,
detail="Produktet mangler både navn og varenummer og kan derfor ikke importeres",
)
supplier_code = product.get("supplier_code")
sku = product.get("sku")
@ -343,7 +452,7 @@ def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optiona
_upsert_product_supplier(existing_by_sku["id"], product, source="gateway")
return existing_by_sku
ean = (product.get("ean") or "").strip()
ean = str(product.get("ean") or "").strip()
if ean:
existing_by_ean = execute_query_single(
"SELECT * FROM products WHERE ean = %s AND deleted_at IS NULL",
@ -461,12 +570,18 @@ async def search_apigw_products(
)
data = await response.json()
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
normalized_query, tokens = _normalize_query(q)
data["products"].sort(
key=lambda product: _score_apigw_product(product, normalized_query, tokens),
reverse=True,
)
if isinstance(data, dict) and isinstance(data.get("products"), list):
data["products"] = [
_normalize_apigw_product(product)
for product in data["products"]
if isinstance(product, dict)
]
if q:
normalized_query, tokens = _normalize_query(q)
data["products"].sort(
key=lambda product: _score_apigw_product(product, normalized_query, tokens),
reverse=True,
)
return data
except HTTPException:
@ -490,7 +605,7 @@ async def search_or_create_product_from_apigw(
):
"""
Local-first product lookup by EAN/SKU-like code.
If not found locally, search APIGateway and optionally auto-create locally from best match.
If not found locally, search API Gateway and optionally auto-create locally from best match.
"""
search_code = (code or "").strip()
if not search_code:
@ -532,6 +647,11 @@ async def search_or_create_product_from_apigw(
products = data.get("products") if isinstance(data, dict) else []
products = products if isinstance(products, list) else []
products = [
_normalize_apigw_product(product)
for product in products
if isinstance(product, dict)
]
best_match = _pick_best_apigw_match(products, search_code)
if not best_match:
return {
@ -539,7 +659,7 @@ async def search_or_create_product_from_apigw(
"source": "apigateway",
"created": False,
"query": search_code,
"message": "Ingen match i APIGateway",
"message": "Ingen match i API Gateway",
}
if not auto_create:

View File

@ -125,14 +125,22 @@
.gateway-result {
display: flex;
align-items: center;
align-items: stretch;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
padding: 14px 16px;
border: 1px solid var(--products-border);
border-radius: 12px;
margin-bottom: 10px;
background: var(--products-card);
box-shadow: 0 5px 18px rgba(15, 76, 117, 0.06);
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.gateway-result:hover {
border-color: rgba(15, 76, 117, 0.35);
box-shadow: 0 10px 26px rgba(15, 76, 117, 0.11);
transform: translateY(-1px);
}
.gateway-result h6 {
@ -145,6 +153,101 @@
font-size: 0.85rem;
}
.gateway-result-main {
flex: 1 1 auto;
min-width: 0;
}
.gateway-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 7px 18px;
margin-top: 10px;
}
.gateway-info-item {
min-width: 0;
color: var(--products-ink);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.gateway-info-label {
display: block;
margin-bottom: 1px;
color: var(--products-ink-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.gateway-result-actions {
display: flex;
min-width: 150px;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
}
.gateway-price {
color: var(--products-ink);
font-size: 1.05rem;
font-weight: 700;
white-space: nowrap;
}
.gateway-catalog-dialog {
width: min(1680px, calc(100vw - 32px));
max-width: none;
margin: 16px auto;
}
.gateway-catalog-modal {
overflow: hidden;
border: 0;
border-radius: 18px;
box-shadow: 0 30px 80px rgba(15, 42, 66, 0.28);
}
.gateway-catalog-modal .modal-header {
padding: 18px 24px;
color: #fff;
background: linear-gradient(120deg, #0f4c75 0%, #176b8f 55%, #1f7fa4 100%);
border-bottom: 0;
}
.gateway-catalog-modal .modal-header .btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
.gateway-catalog-modal .modal-body {
max-height: calc(100vh - 100px);
padding: 22px 24px 28px;
overflow-y: auto;
background: #f7f9fb;
}
.gateway-search-panel {
padding: 16px;
margin-bottom: 20px;
background: #fff;
border: 1px solid var(--products-border);
border-radius: 14px;
box-shadow: 0 5px 18px rgba(15, 76, 117, 0.05);
}
.gateway-results-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.gateway-results-grid .gateway-result {
height: 100%;
margin-bottom: 0;
}
.products-card .card-header {
background: transparent;
border-bottom: 1px solid var(--products-border);
@ -184,6 +287,29 @@
.products-page {
padding: 20px;
}
.gateway-result {
flex-direction: column;
}
.gateway-result-actions {
width: 100%;
flex-direction: row;
align-items: center;
}
.gateway-catalog-dialog {
width: calc(100vw - 16px);
margin: 8px auto;
}
.gateway-catalog-modal .modal-body {
padding: 14px;
}
.gateway-results-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
@ -202,7 +328,7 @@
<i class="bi bi-plus-circle"></i> Opret Produkt
</button>
<button class="btn btn-outline-primary ms-2" onclick="openGatewaySearchModal()">
<i class="bi bi-cloud-download"></i> Import fra gateway
<i class="bi bi-search"></i> Søg hos grossister
</button>
</div>
</div>
@ -234,8 +360,8 @@
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
<i class="bi bi-search"></i> Søg
</button>
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)
<button class="btn btn-primary" onclick="searchGatewayCatalogFromLocalQuery()" id="gatewayCatalogSearchBtn">
<i class="bi bi-search"></i> Søg hos alle grossister
</button>
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
<i class="bi bi-x"></i> Nulstil
@ -289,46 +415,58 @@
</div>
<div class="modal fade" id="gatewaySearchModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-dialog gateway-catalog-dialog">
<div class="modal-content gateway-catalog-modal">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-cloud-arrow-down"></i> Import fra API Gateway</h5>
<h5 class="modal-title"><i class="bi bi-cloud"></i> Grossistkatalog</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-md-6">
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Søg efter produktnavn...">
<div class="gateway-search-panel">
<div class="alert alert-info py-2 mb-3" role="status">
<i class="bi bi-info-circle me-1"></i>
Søgningen opretter ikke varer i Hub'en. Der søges hos alle grossister, medmindre du vælger en bestemt leverandør.
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Supplier code">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" onclick="searchGatewayProducts(true)">
<i class="bi bi-search"></i> Søg
</button>
</div>
<div class="col-md-4">
<input type="number" class="form-control" id="gatewayModalMinPrice" placeholder="Min pris">
</div>
<div class="col-md-4">
<input type="number" class="form-control" id="gatewayModalMaxPrice" placeholder="Max pris">
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="gatewayModalInStock">
<label class="form-check-label" for="gatewayModalInStock">Kun på lager</label>
<div class="row g-2">
<div class="col-lg-5">
<label class="form-label small fw-semibold" for="gatewayModalQuery">Søgeord, varenummer eller EAN</label>
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Fx Lenovo dockingstation eller 570...">
</div>
<div class="col-lg-3 col-md-6">
<label class="form-label small fw-semibold" for="gatewayModalSupplier">Grossist</label>
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Alle grossister">
</div>
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-semibold" for="gatewayModalManufacturer">Producent</label>
<input type="text" class="form-control" id="gatewayModalManufacturer" placeholder="Alle producenter">
</div>
<div class="col-lg-2 col-md-3 d-grid align-items-end">
<button class="btn btn-primary" onclick="searchGatewayProducts(true)">
<i class="bi bi-search me-1"></i> Søg i kataloget
</button>
</div>
<div class="col-lg-3 col-md-6">
<label class="form-label small fw-semibold" for="gatewayModalCategory">Kategori</label>
<input type="text" class="form-control" id="gatewayModalCategory" placeholder="Alle kategorier">
</div>
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-semibold" for="gatewayModalMinPrice">Pris fra</label>
<input type="number" class="form-control" id="gatewayModalMinPrice" placeholder="0">
</div>
<div class="col-lg-2 col-md-3">
<label class="form-label small fw-semibold" for="gatewayModalMaxPrice">Pris til</label>
<input type="number" class="form-control" id="gatewayModalMaxPrice" placeholder="Ingen grænse">
</div>
<div class="col-lg-3 col-md-6 d-flex align-items-end pb-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="gatewayModalInStock">
<label class="form-check-label" for="gatewayModalInStock">Vis kun varer på lager</label>
</div>
</div>
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="gatewayModalCategory" placeholder="Kategori">
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="gatewayModalManufacturer" placeholder="Producent">
</div>
</div>
<div id="gatewayResults">
<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>
<div class="text-center text-muted py-4">Søg på navn, varenummer, EAN, producent eller kategori.</div>
</div>
</div>
</div>
@ -451,7 +589,11 @@ async function loadProducts() {
const params = buildLocalProductParams();
const queryString = params.toString();
const url = queryString ? `/api/v1/products?${queryString}` : '/api/v1/products';
const products = await fetch(url).then(r => r.json());
const response = await fetch(url, { credentials: 'include' });
const products = await response.json().catch(() => []);
if (!response.ok) {
throw new Error(products?.detail || `Produkter kunne ikke hentes (HTTP ${response.status})`);
}
localProductsState.products = Array.isArray(products) ? products : [];
localProductsState.page = 1;
localProductsState.query = document.getElementById('localSearchQuery')?.value.trim() || '';
@ -603,58 +745,10 @@ function showLocalProductMessage(message, level = 'info') {
meta.textContent = `${prefix}${message}`;
}
async function searchAndCreateByGatewayCode() {
function searchGatewayCatalogFromLocalQuery() {
const queryInput = document.getElementById('localSearchQuery');
const button = document.getElementById('gatewayBarcodeSyncBtn');
const code = queryInput ? queryInput.value.trim() : '';
if (!code) {
alert('Skriv EAN eller stregkode i søgefeltet først');
return;
}
const oldHtml = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Søger...';
}
try {
const params = new URLSearchParams({ code, auto_create: 'true' });
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
credentials: 'include'
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.detail || 'Sogning i APIGateway fejlede');
}
if (!body.found) {
showLocalProductMessage('Ingen match i APIGateway for den strengkode', 'error');
alert('Ingen produkt fundet i APIGateway for den strengkode');
return;
}
if (body.source === 'local') {
showLocalProductMessage('Produkt fundet lokalt', 'success');
} else if (body.created) {
showLocalProductMessage('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
} else {
showLocalProductMessage('Produkt fundet via APIGateway', 'info');
}
await loadProducts();
} catch (error) {
console.error(error);
showLocalProductMessage(error.message || 'Sogning fejlede', 'error');
alert(error.message || 'Sogning fejlede');
} finally {
if (button) {
button.disabled = false;
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)';
}
}
const query = queryInput ? queryInput.value.trim() : '';
openGatewaySearchModal(query, Boolean(query));
}
function clearLocalProductSearch() {
@ -719,7 +813,7 @@ function setupLocalSearchEvents() {
if (event.key === 'Enter') {
event.preventDefault();
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
searchAndCreateByGatewayCode();
searchGatewayCatalogFromLocalQuery();
} else {
applyLocalProductSearch();
}
@ -802,15 +896,15 @@ async function createProduct() {
await loadProducts();
}
function openGatewaySearchModal(autoSearch = false) {
document.getElementById('gatewayModalQuery').value = '';
function openGatewaySearchModal(initialQuery = '', autoSearch = false) {
document.getElementById('gatewayModalQuery').value = initialQuery;
document.getElementById('gatewayModalSupplier').value = '';
document.getElementById('gatewayModalCategory').value = '';
document.getElementById('gatewayModalManufacturer').value = '';
document.getElementById('gatewayModalMinPrice').value = '';
document.getElementById('gatewayModalMaxPrice').value = '';
document.getElementById('gatewayModalInStock').checked = false;
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>';
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg på navn, varenummer, EAN, producent eller kategori.</div>';
new bootstrap.Modal(document.getElementById('gatewaySearchModal')).show();
if (autoSearch) {
searchGatewayProducts(true);
@ -819,7 +913,7 @@ function openGatewaySearchModal(autoSearch = false) {
async function searchGatewayProducts(fromModal = false) {
if (!fromModal) {
openGatewaySearchModal(true);
openGatewaySearchModal('', true);
return;
}
const queryInput = document.getElementById('gatewayModalQuery');
@ -835,12 +929,13 @@ async function searchGatewayProducts(fromModal = false) {
const category = categoryInput ? categoryInput.value.trim() : '';
const manufacturer = manufacturerInput ? manufacturerInput.value.trim() : '';
const inStock = inStockInput ? inStockInput.checked : false;
if (!q && !supplier) {
alert('Angiv søgeord eller leverandørkode');
if (!q && !supplier && !category && !manufacturer && !inStock && !minPrice && !maxPrice) {
alert('Angiv mindst ét søgekriterium');
return;
}
const params = new URLSearchParams();
params.append('per_page', '100');
if (q) params.append('q', q);
if (supplier) params.append('supplier_code', supplier);
if (category) params.append('category', category);
@ -855,18 +950,19 @@ async function searchGatewayProducts(fromModal = false) {
}
try {
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`);
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`, {
credentials: 'include'
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const error = await res.text();
throw new Error(error || 'Gateway-søgning fejlede');
throw new Error(data.detail || `Gateway-søgning fejlede (HTTP ${res.status})`);
}
const data = await res.json();
const products = Array.isArray(data.products) ? data.products : [];
setGatewayResults(products, q);
} catch (e) {
console.error(e);
if (resultsContainer) {
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved søgning'}</div>`;
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${escapeHtml(e.message || 'Fejl ved søgning')}</div>`;
}
}
}
@ -890,6 +986,16 @@ function escapeHtml(value) {
}[ch]));
}
function safeGatewayUrl(value) {
if (!value) return '';
try {
const url = new URL(String(value), window.location.origin);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_error) {
return '';
}
}
function normalizeTokens(value) {
return String(value || '')
.toLowerCase()
@ -937,12 +1043,91 @@ function highlightTokens(text, tokens) {
}
function setGatewayResults(products, query) {
gatewayResultsState.products = products;
gatewayResultsState.products = products.map(normalizeGatewayProduct);
gatewayResultsState.query = query || '';
gatewayResultsState.page = 1;
renderGatewayResults();
}
function normalizeGatewayProduct(product) {
const normalized = { ...(product || {}) };
normalized.product_name = normalized.product_name
|| normalized.name
|| normalized.productName
|| normalized.title
|| normalized.description
|| (normalized.sku ? `Produkt · ${normalized.sku}` : 'Unavngivet produkt');
normalized.category = normalized.category
|| normalized.legacy_category
|| normalized.primary_category?.name
|| '';
normalized.sku = normalized.sku || normalized.supplier_sku || normalized.item_number || normalized.part_number || '';
normalized.ean = normalized.ean || normalized.ean13 || normalized.gtin || normalized.barcode || '';
normalized.supplier_name = normalized.supplier_name || normalized.supplier?.name || '';
normalized.supplier_code = normalized.supplier_code || normalized.supplier?.code || '';
normalized.stock_qty = normalized.stock_qty ?? normalized.stock ?? normalized.quantity ?? normalized.available_stock;
normalized.stock_status = normalized.stock_status || normalized.availability || normalized.inventory_status || '';
normalized.currency = normalized.currency || normalized.supplier_currency || 'DKK';
return normalized;
}
function formatGatewayCurrency(amount, currency = 'DKK') {
const numericAmount = Number(amount);
const safeAmount = Number.isFinite(numericAmount) ? numericAmount : 0;
const safeCurrency = /^[A-Z]{3}$/.test(String(currency || '').toUpperCase())
? String(currency).toUpperCase()
: 'DKK';
try {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: safeCurrency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(safeAmount);
} catch (_error) {
return `${safeAmount.toFixed(2)} ${safeCurrency}`;
}
}
function gatewayInfoItem(label, value) {
if (value === null || value === undefined || String(value).trim() === '') return '';
return `<div class="gateway-info-item"><span class="gateway-info-label">${escapeHtml(label)}</span>${escapeHtml(value)}</div>`;
}
function renderGatewayProductCard(product, tokens) {
const encoded = encodeURIComponent(JSON.stringify(product));
const supplierUrl = safeGatewayUrl(product.product_url || product.url || product.product_link || product.supplier_url || product.supplier_product_url);
const name = highlightTokens(product.product_name || product.name || '', tokens);
const stockParts = [];
if (product.stock_qty !== null && product.stock_qty !== undefined && product.stock_qty !== '') {
stockParts.push(`${product.stock_qty} stk.`);
}
if (product.stock_status) stockParts.push(product.stock_status);
const supplier = [product.supplier_name, product.supplier_code].filter(Boolean).join(' · ');
const details = [
gatewayInfoItem('Grossist', supplier),
gatewayInfoItem('Varenummer', product.sku),
gatewayInfoItem('EAN / GTIN', product.ean),
gatewayInfoItem('Producent', product.manufacturer),
gatewayInfoItem('Kategori', product.category),
gatewayInfoItem('Lager', stockParts.join(' · '))
].join('');
return `
<div class="gateway-result">
<div class="gateway-result-main">
<h6>${name}</h6>
<div class="gateway-info-grid">${details || gatewayInfoItem('Oplysninger', 'Ingen yderligere produktdata')}</div>
${supplierUrl ? `<div class="gateway-meta mt-2"><a href="${escapeHtml(supplierUrl)}" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Se varen hos grossisten</a></div>` : ''}
</div>
<div class="gateway-result-actions">
<div class="gateway-price">${formatGatewayCurrency(product.price, product.currency)}</div>
<button class="btn btn-sm btn-outline-primary mt-2" data-product="${encoded}" onclick="importGatewayProductFromAttr(this)">Importér til Hub</button>
</div>
</div>
`;
}
function changeGatewaySort(value) {
gatewayResultsState.sort = value;
gatewayResultsState.page = 1;
@ -1050,67 +1235,29 @@ function renderGatewayResults() {
const bestMatchBlock = bestMatches.length ? `
<div class="mb-3">
<div class="fw-semibold mb-2">Bedste match</div>
${bestMatches.map(product => {
const encoded = encodeURIComponent(JSON.stringify(product));
const supplierUrl = product.product_url || product.url || product.product_link || product.supplier_url || product.supplier_product_url;
const name = highlightTokens(product.product_name || product.name || '', tokens);
const sku = product.sku ? `<span class="badge bg-light text-dark border">${escapeHtml(product.sku)}</span>` : '';
const manufacturer = product.manufacturer ? `<span class="badge bg-light text-dark border">${escapeHtml(product.manufacturer)}</span>` : '';
const supplier = product.supplier_name ? `<span class="badge bg-light text-dark border">${escapeHtml(product.supplier_name)}</span>` : '';
const category = product.category ? `<span class="text-muted small">${escapeHtml(product.category)}</span>` : '';
const stock = product.stock_status ? `<span class="text-muted small">• ${escapeHtml(product.stock_status)}</span>` : '';
return `
<div class="gateway-result">
<div>
<h6 class="mb-1">${name}</h6>
<div class="gateway-meta d-flex flex-wrap gap-2">${sku}${manufacturer}${supplier}</div>
<div class="gateway-meta">${category} ${stock}</div>
${supplierUrl ? `<div class="gateway-meta"><a href="${supplierUrl}" target="_blank" rel="noopener">Se hos grosist</a></div>` : ''}
</div>
<div class="text-end">
<div class="fw-semibold">${formatCurrency(product.price || 0)}</div>
<button class="btn btn-sm btn-outline-primary mt-2" data-product="${encoded}" onclick="importGatewayProductFromAttr(this)">Importer</button>
</div>
</div>
`;
}).join('')}
<div class="gateway-results-grid">
${bestMatches.map(product => renderGatewayProductCard(product, tokens)).join('')}
</div>
</div>
<div class="fw-semibold mb-2">Alle resultater</div>
` : '';
const rows = visibleItems.map(product => {
const encoded = encodeURIComponent(JSON.stringify(product));
const supplierUrl = product.product_url || product.url || product.product_link || product.supplier_url || product.supplier_product_url;
const name = highlightTokens(product.product_name || product.name || '', tokens);
const sku = product.sku ? `<span class="badge bg-light text-dark border">${escapeHtml(product.sku)}</span>` : '';
const manufacturer = product.manufacturer ? `<span class="badge bg-light text-dark border">${escapeHtml(product.manufacturer)}</span>` : '';
const supplier = product.supplier_name ? `<span class="badge bg-light text-dark border">${escapeHtml(product.supplier_name)}</span>` : '';
const category = product.category ? `<span class="text-muted small">${escapeHtml(product.category)}</span>` : '';
const stock = product.stock_status ? `<span class="text-muted small">• ${escapeHtml(product.stock_status)}</span>` : '';
return `
<div class="gateway-result">
<div>
<h6 class="mb-1">${name}</h6>
<div class="gateway-meta d-flex flex-wrap gap-2">${sku}${manufacturer}${supplier}</div>
<div class="gateway-meta">${category} ${stock}</div>
${supplierUrl ? `<div class="gateway-meta"><a href="${supplierUrl}" target="_blank" rel="noopener">Se hos grosist</a></div>` : ''}
</div>
<div class="text-end">
<div class="fw-semibold">${formatCurrency(product.price || 0)}</div>
<button class="btn btn-sm btn-outline-primary mt-2" data-product="${encoded}" onclick="importGatewayProductFromAttr(this)">Importer</button>
</div>
</div>
`;
}).join('');
const rows = visibleItems.map(product => renderGatewayProductCard(product, tokens)).join('');
resultsContainer.innerHTML = header + bestMatchBlock + rows;
resultsContainer.innerHTML = header + bestMatchBlock + `<div class="gateway-results-grid">${rows}</div>`;
}
async function importGatewayProduct(product) {
async function importGatewayProduct(product, button = null) {
const oldHtml = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Importerer...';
}
try {
const res = await fetch('/api/v1/products/apigateway/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ product })
});
if (!res.ok) {
@ -1122,6 +1269,11 @@ async function importGatewayProduct(product) {
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-success py-4">Produkt importeret.</div>';
} catch (e) {
alert(e.message || e);
} finally {
if (button?.isConnected) {
button.disabled = false;
button.innerHTML = oldHtml;
}
}
}
@ -1130,7 +1282,7 @@ function importGatewayProductFromAttr(button) {
if (!payload) return;
try {
const product = JSON.parse(decodeURIComponent(payload));
importGatewayProduct(product);
importGatewayProduct(product, button);
} catch (e) {
alert('Kunne ikke læse produktdata');
}

View File

@ -679,6 +679,9 @@
.global-bottom-bar .bb-panel-tasks li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.global-bottom-bar .bb-panel-empty { min-height: 140px; display: grid; place-items: center; text-align: center; color: var(--text-secondary); }
.global-bottom-bar .bb-panel-empty i { display: block; font-size: 1.8rem; color: color-mix(in srgb, var(--accent) 55%, var(--text-secondary)); margin-bottom: 0.35rem; }
.global-bottom-bar .bb-location-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.5rem; overflow-y: auto; min-height: 0; padding-right: 0.25rem; }
.global-bottom-bar .bb-location-card { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; min-height: 58px; padding: 0.55rem 0.65rem; color: inherit; text-decoration: none; border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 10px; background: var(--bg-card); }
.global-bottom-bar .bb-location-card:hover { color: inherit; border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 5%, var(--bg-card)); }
.global-bottom-bar .bb-live-indicator { width: 0.58rem; height: 0.58rem; border-radius: 50%; background: #198754; box-shadow: 0 0 0 5px rgba(25, 135, 84, 0.12); display: inline-block; margin-right: 0.55rem; }
.global-bottom-bar .bb-detail-line {
min-height: 28px;
@ -1458,6 +1461,7 @@
<button class="bb-tab-btn" type="button" data-bb-tab="messages" role="tab" aria-selected="false"><i class="bi bi-chat-dots"></i> Beskeder</button>
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
<button class="bb-tab-btn" type="button" data-bb-tab="locations" role="tab" aria-selected="false"><i class="bi bi-geo-alt"></i> Lokationer</button>
<!-- Vises kun for chefer, men her i markup -->
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-diagram-3"></i> Sagsfordeling</button>
</div>
@ -1586,7 +1590,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.5"></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/bottom-bar.js?v=2.64"></script>
<script src="/static/js/bottom-bar.js?v=2.65"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true;

View File

@ -62,6 +62,22 @@ services:
- FEDEX_API_SECRET=${FEDEX_API_SECRET}
- FEDEX_ACCOUNT_NUMBER=${FEDEX_ACCOUNT_NUMBER}
- FEDEX_BASE_URL=${FEDEX_BASE_URL}
- SHIPMONDO_ENABLED=${SHIPMONDO_ENABLED:-false}
- SHIPMONDO_READ_ONLY=${SHIPMONDO_READ_ONLY:-true}
- SHIPMONDO_DRY_RUN=${SHIPMONDO_DRY_RUN:-true}
- SHIPMONDO_API_BASE_URL=${SHIPMONDO_API_BASE_URL:-https://app.shipmondo.com/api/public/v3}
- SHIPMONDO_API_USER=${SHIPMONDO_API_USER:-}
- SHIPMONDO_API_KEY=${SHIPMONDO_API_KEY:-}
- SHIPMONDO_TIMEOUT_SECONDS=${SHIPMONDO_TIMEOUT_SECONDS:-30}
- SHIPMONDO_SENDER_NAME=${SHIPMONDO_SENDER_NAME:-BMC Networks}
- SHIPMONDO_SENDER_ATTENTION=${SHIPMONDO_SENDER_ATTENTION:-}
- SHIPMONDO_SENDER_ADDRESS1=${SHIPMONDO_SENDER_ADDRESS1:-}
- SHIPMONDO_SENDER_ADDRESS2=${SHIPMONDO_SENDER_ADDRESS2:-}
- SHIPMONDO_SENDER_POSTAL_CODE=${SHIPMONDO_SENDER_POSTAL_CODE:-}
- SHIPMONDO_SENDER_CITY=${SHIPMONDO_SENDER_CITY:-}
- SHIPMONDO_SENDER_COUNTRY_CODE=${SHIPMONDO_SENDER_COUNTRY_CODE:-DK}
- SHIPMONDO_SENDER_EMAIL=${SHIPMONDO_SENDER_EMAIL:-}
- SHIPMONDO_SENDER_PHONE=${SHIPMONDO_SENDER_PHONE:-}
- FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS}
restart: unless-stopped
logging:

View File

@ -135,6 +135,7 @@ from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
from app.modules.fedex.backend import router as fedex_api
from app.modules.fedex.frontend import views as fedex_views
from app.modules.shipmondo.backend import router as shipmondo_api
from app.modules.also.backend import router as also_api
from app.modules.manual.backend import router as manual_api
from app.modules.manual.frontend import views as manual_views
@ -493,6 +494,7 @@ app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"])
app.include_router(also_api.router, prefix="/api/v1", tags=["ALSO Cloud Billing"])
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])

View File

@ -0,0 +1,74 @@
-- Migration 1017: Shipmondo shipments linked to cases
CREATE TABLE IF NOT EXISTS shipmondo_shipments (
id SERIAL PRIMARY KEY,
booking_ref VARCHAR(64) NOT NULL UNIQUE,
shipmondo_id VARCHAR(64),
case_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
product_code VARCHAR(80) NOT NULL,
product_name VARCHAR(180),
carrier_code VARCHAR(80),
carrier_name VARCHAR(120),
service_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
shipment_status VARCHAR(32) NOT NULL DEFAULT 'draft',
own_agreement BOOLEAN NOT NULL DEFAULT FALSE,
automatic_select_service_point BOOLEAN NOT NULL DEFAULT FALSE,
recipient_name VARCHAR(150) NOT NULL,
company_name VARCHAR(150),
address_line1 VARCHAR(200) NOT NULL,
address_line2 VARCHAR(200),
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
country_code VARCHAR(2) NOT NULL,
phone VARCHAR(50),
email VARCHAR(150),
tracking_number VARCHAR(128),
tracking_url TEXT,
total_amount NUMERIC(12,2),
currency VARCHAR(8),
dry_run BOOLEAN NOT NULL DEFAULT FALSE,
submitted_at TIMESTAMP,
api_payload JSONB,
api_response JSONB,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
CONSTRAINT shipmondo_shipments_status_check CHECK (
shipment_status IN ('draft', 'submitted', 'booked', 'in_transit', 'delivered', 'cancelled', 'failed')
)
);
CREATE TABLE IF NOT EXISTS shipmondo_shipment_parcels (
id SERIAL PRIMARY KEY,
shipment_id INTEGER NOT NULL REFERENCES shipmondo_shipments(id) ON DELETE CASCADE,
weight_kg NUMERIC(10,3) NOT NULL,
length_cm NUMERIC(10,2),
width_cm NUMERIC(10,2),
height_cm NUMERIC(10,2),
description VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT shipmondo_parcel_weight_positive CHECK (weight_kg > 0)
);
CREATE INDEX IF NOT EXISTS idx_shipmondo_shipments_case_id ON shipmondo_shipments(case_id);
CREATE INDEX IF NOT EXISTS idx_shipmondo_shipments_status ON shipmondo_shipments(shipment_status);
CREATE INDEX IF NOT EXISTS idx_shipmondo_shipments_created_at ON shipmondo_shipments(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_shipmondo_parcels_shipment_id ON shipmondo_shipment_parcels(shipment_id);
CREATE OR REPLACE FUNCTION update_shipmondo_shipments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS shipmondo_shipments_updated_at_trigger ON shipmondo_shipments;
CREATE TRIGGER shipmondo_shipments_updated_at_trigger
BEFORE UPDATE ON shipmondo_shipments
FOR EACH ROW
EXECUTE FUNCTION update_shipmondo_shipments_updated_at();

View File

@ -0,0 +1,17 @@
-- Migration 1018: Preserve a trace from Economy Time Queue to the local order draft.
-- A time entry must leave the queue when a local draft has been created, while
-- still remaining available to the separate order/export workflow.
ALTER TABLE tmodule_times
ADD COLUMN IF NOT EXISTS economy_order_draft_id INTEGER
REFERENCES ordre_drafts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS economy_settled_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS economy_settled_by INTEGER
REFERENCES users(user_id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tmodule_times_economy_order_draft
ON tmodule_times(economy_order_draft_id)
WHERE economy_order_draft_id IS NOT NULL;
COMMENT ON COLUMN tmodule_times.economy_order_draft_id IS
'Local ordrekladde created by Economy Time Queue. Prevents a duplicate draft for the same time.';

View File

@ -3,6 +3,7 @@
let latestContextActions = { global: [], context: [] };
let activeKey = 'timer';
let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' };
let locationsPanelState = { loading: false, loaded: false, rows: [], error: '', query: '' };
let overviewFilter = null;
let ws = null;
let pollTimer = null;
@ -1235,6 +1236,7 @@
messages: 'Beskeder',
tasks: 'Opgaver',
notes: 'Noter',
locations: 'Lokationer',
boss: 'Sagsfordeling'
};
@ -1244,6 +1246,7 @@
messages: 'bi-chat-dots',
tasks: 'bi-calendar-check',
notes: 'bi-journal-text',
locations: 'bi-geo-alt',
boss: 'bi-person-workspace'
};
@ -1253,6 +1256,7 @@
messages: 'Interne samtaler samlet efter modtager med tydelig læst-status.',
tasks: 'Prioritér næste handling ud fra deadlines og aktuelle påmindelser.',
notes: 'Skriv hurtigt til venstre og genbrug dine noter fra arkivet til højre.',
locations: 'Find og åbn aktive kundelokationer uden at forlade dit arbejde.',
boss: 'Fordel supportkøen ud fra kapacitet, hast og ventetid.'
};
@ -1290,6 +1294,11 @@
renderTimerWorkQueue(innerContent);
return;
}
if (activeKey === 'locations') {
renderLocationsPanel(innerContent);
return;
}
// Add specific headers/controls based on active tab
if (activeKey === 'tasks') {
@ -1509,6 +1518,79 @@
container.appendChild(list);
}
function locationTypeLabel(value) {
const labels = {
kompleks: 'Kompleks', bygning: 'Bygning', etage: 'Etage',
customer_site: 'Kundelokation', rum: 'Rum', kantine: 'Kantine',
moedelokale: 'Mødelokale', vehicle: 'Køretøj'
};
return labels[String(value || '').toLowerCase()] || 'Lokation';
}
async function loadLocationsPanel(force) {
if (locationsPanelState.loading || (locationsPanelState.loaded && !force)) return;
locationsPanelState.loading = true;
locationsPanelState.error = '';
try {
const response = await fetch('/api/v1/locations?is_active=true&limit=100', { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente lokationer');
const payload = await response.json();
locationsPanelState.rows = Array.isArray(payload) ? payload : [];
locationsPanelState.loaded = true;
} catch (err) {
locationsPanelState.error = err && err.message ? err.message : 'Kunne ikke hente lokationer';
} finally {
locationsPanelState.loading = false;
if (activeKey === 'locations') renderTabPanel();
}
}
function renderLocationsPanel(container) {
const query = String(locationsPanelState.query || '').trim().toLowerCase();
const rows = locationsPanelState.rows.filter(function (row) {
if (!query) return true;
return [row.name, row.customer_name, row.address_street, row.address_city, row.location_type]
.some(function (value) { return String(value || '').toLowerCase().includes(query); });
});
const toolbar = document.createElement('div');
toolbar.className = 'd-flex gap-2 align-items-center mb-3';
toolbar.innerHTML = '<div class="input-group input-group-sm flex-grow-1">'
+ '<span class="input-group-text"><i class="bi bi-search"></i></span>'
+ '<input id="bbLocationsSearch" class="form-control" type="search" placeholder="Søg navn, kunde eller by" value="' + escapeHtml(locationsPanelState.query || '') + '">'
+ '</div><button type="button" class="btn btn-sm btn-outline-secondary" id="bbLocationsRefresh" title="Opdater"><i class="bi bi-arrow-clockwise"></i></button>'
+ '<a class="btn btn-sm btn-primary" href="/app/locations"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn alle</a>';
container.appendChild(toolbar);
if (locationsPanelState.loading && !locationsPanelState.loaded) {
container.insertAdjacentHTML('beforeend', '<div class="bb-panel-empty"><div><i class="bi bi-geo-alt"></i>Henter lokationer...</div></div>');
} else if (locationsPanelState.error) {
container.insertAdjacentHTML('beforeend', '<div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-1"></i>' + escapeHtml(locationsPanelState.error) + '</div>');
} else if (!rows.length) {
container.insertAdjacentHTML('beforeend', '<div class="bb-panel-empty"><div><i class="bi bi-geo-alt"></i><strong>Ingen lokationer fundet</strong><div class="small mt-1">Prøv en anden søgning eller åbn lokationsmodulet.</div></div></div>');
} else {
const list = document.createElement('div');
list.className = 'bb-location-list';
rows.slice(0, 40).forEach(function (row) {
const address = [row.address_street, [row.address_postal_code, row.address_city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const customer = row.customer_name ? '<span><i class="bi bi-building me-1"></i>' + escapeHtml(row.customer_name) + '</span>' : '';
const addressHtml = address ? '<span><i class="bi bi-geo me-1"></i>' + escapeHtml(address) + '</span>' : '';
const item = document.createElement('a');
item.className = 'bb-location-card';
item.href = '/app/locations/' + Number(row.id || 0);
item.innerHTML = '<div class="min-w-0"><div class="fw-semibold text-truncate">' + escapeHtml(row.name || 'Unavngivet lokation') + '</div>'
+ '<div class="small text-muted d-flex flex-wrap gap-2 mt-1"><span class="badge text-bg-light border">' + escapeHtml(locationTypeLabel(row.location_type)) + '</span>' + customer + addressHtml + '</div></div>'
+ '<i class="bi bi-chevron-right text-muted"></i>';
list.appendChild(item);
});
container.appendChild(list);
}
const input = container.querySelector('#bbLocationsSearch');
if (input) input.addEventListener('input', function () { locationsPanelState.query = input.value; renderTabPanel(); });
const refresh = container.querySelector('#bbLocationsRefresh');
if (refresh) refresh.addEventListener('click', function () { loadLocationsPanel(true); });
if (!locationsPanelState.loaded && !locationsPanelState.loading) loadLocationsPanel();
}
function ownTimerRows(payload) {
const groups = normalizeSwitchableTimerPayload(payload);
return groups.active.map(function (row) {

View File

@ -0,0 +1,35 @@
from pathlib import Path
from app.economy.backend.router import _normalise_billing_method, _settlement_label
ROOT = Path(__file__).resolve().parents[1]
def test_time_queue_normalises_legacy_settlement_names():
assert _normalise_billing_method("prepaid_card") == "prepaid"
assert _normalise_billing_method("fixed_price") == "subscription"
assert _normalise_billing_method(None) == "invoice"
assert _settlement_label("subscription") == "Abonnement / fast pris"
def test_time_queue_has_preview_and_safe_draft_tracking():
router_source = (ROOT / "app/economy/backend/router.py").read_text()
template_source = (ROOT / "app/economy/frontend/time_queue.html").read_text()
migration_source = (ROOT / "migrations/1018_economy_time_queue_settlement.sql").read_text()
assert '"/time-queue/preview-settlement"' in router_source
assert '"/time-queue/settle"' in router_source
assert "economy_order_draft_id" in router_source
assert "preview-settlement" in template_source
assert "Forhåndsvisning af afregning" in template_source
assert "economy_order_draft_id" in migration_source
def test_case_customer_is_authoritative_for_queue_and_settlement():
router_source = (ROOT / "app/economy/backend/router.py").read_text()
# A moved/corrected case must not be invoiced to the historic customer
# stored on a time entry.
assert "COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id" in router_source
assert "The case company is authoritative" in router_source

View File

@ -0,0 +1,95 @@
import importlib
from pathlib import Path
import pytest
from fastapi import HTTPException
products_router = importlib.import_module("app.products.backend.router")
def test_normalize_apigw_product_accepts_nested_aliases():
normalized = products_router._normalize_apigw_product(
{
"product": {
"productName": "Managed switch",
"part_number": "SW-24",
"barcode": "570000000001",
"net_price": 499.95,
"legacy_category": "Netværk",
"supplier": {"name": "Grossist", "code": "grossist"},
}
}
)
assert normalized["product_name"] == "Managed switch"
assert normalized["sku"] == "SW-24"
assert normalized["ean"] == "570000000001"
assert normalized["price"] == 499.95
assert normalized["category"] == "Netværk"
assert normalized["supplier_name"] == "Grossist"
assert normalized["supplier_code"] == "grossist"
def test_normalize_apigw_product_builds_name_from_item_code():
normalized = products_router._normalize_apigw_product(
{"sku": "ABC-123", "supplier_name": "DCS"}
)
assert normalized["product_name"] == "DCS · ABC-123"
def test_normalize_apigw_product_does_not_unwrap_a_real_product_data_field():
normalized = products_router._normalize_apigw_product(
{"product_name": "Router", "sku": "RTR-1", "data": {"irrelevant": True}}
)
assert normalized["product_name"] == "Router"
assert normalized["sku"] == "RTR-1"
def test_import_apigw_product_rejects_only_truly_unidentifiable_item():
with pytest.raises(HTTPException) as exc_info:
products_router._import_apigw_product_to_local({"product": {"id": 123}})
assert exc_info.value.status_code == 400
assert "mangler både navn og varenummer" in exc_info.value.detail
def test_products_page_handles_gateway_errors_and_prevents_double_import():
template = Path("app/products/frontend/list.html").read_text()
assert "const data = await res.json().catch(() => ({}));" in template
assert "escapeHtml(e.message || 'Fejl ved søgning')" in template
assert "async function importGatewayProduct(product, button = null)" in template
assert "button.disabled = true;" in template
assert "importGatewayProduct(product, button);" in template
assert "Angiv mindst ét søgekriterium" in template
assert "function safeGatewayUrl(value)" in template
def test_gateway_catalog_search_is_read_only_until_explicit_import():
template = Path("app/products/frontend/list.html").read_text()
assert "Søg hos alle grossister" in template
assert "Søgningen opretter ikke varer i Hub'en" in template
assert "function searchGatewayCatalogFromLocalQuery()" in template
assert "openGatewaySearchModal(query, Boolean(query));" in template
assert "params.append('per_page', '100');" in template
assert "Importér til Hub" in template
assert "auto_create: 'true'" not in template
def test_gateway_catalog_results_show_supplier_and_product_details():
template = Path("app/products/frontend/list.html").read_text()
assert "function renderGatewayProductCard(product, tokens)" in template
assert "gatewayInfoItem('Grossist', supplier)" in template
assert "gatewayInfoItem('Varenummer', product.sku)" in template
assert "gatewayInfoItem('EAN / GTIN', product.ean)" in template
assert "gatewayInfoItem('Producent', product.manufacturer)" in template
assert "gatewayInfoItem('Kategori', product.category)" in template
assert "gatewayInfoItem('Lager', stockParts.join(' · '))" in template
assert "formatGatewayCurrency(product.price, product.currency)" in template
assert "gateway-catalog-dialog" in template
assert "gateway-search-panel" in template
assert "gateway-results-grid" in template

View File

@ -197,6 +197,29 @@ def test_comment_actions_use_styled_accessible_chips():
assert 'for="commentActionAwaitCustomer"' in template
def test_case_history_shows_actor_timestamp_and_all_case_activity_filters():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "<strong>Hvornår:</strong>" in template
assert "<strong>Hvem:</strong>" in template
assert 'id="historyFilterCaseActivity"' in template
assert "['case', 'tag', 'todo', 'relation']" in template
def test_case_timeline_uses_real_actors_and_action_timestamps():
source = Path("app/modules/sag/backend/router.py").read_text()
assert '"title": "Sag oprettet"' in source
assert 'r.created_at' in source
assert 'employee.user_id = t.medarbejder_id' in source
assert 'LOWER(recorder.username) = LOWER(t.user_name)' in source
assert 'Registreret for: {employee_name}' in source
assert '"forfatter": str(row.get("actor_name") or "System")' in source
assert '"event_type": "tag"' in source
assert '"event_type": "todo"' in source
assert '"event_type": "relation"' in source
def test_safe_case_html_renders_formatting_and_drops_executable_code():
result = sanitize_safe_html(
'<style>body{display:none}</style>'

View File

@ -0,0 +1,113 @@
from pathlib import Path
from app.core.config import settings
from app.modules.shipmondo.backend.service import (
build_shipmondo_payload,
extract_label_base64,
extract_price,
extract_tracking_number,
)
from app.modules.shipmondo.models.schemas import ShipmondoBookingCreate
ROOT = Path(__file__).resolve().parents[1]
def test_shipmondo_schema_normalizes_country_and_services():
payload = ShipmondoBookingCreate.model_validate(
{
"case_id": 88,
"product_code": "GLSDK_HD",
"service_codes": ["email_nt", "EMAIL_NT", "sms_nt"],
"address": {
"recipient_name": "Lene Jensen",
"address_line1": "Vindegade 112",
"postal_code": "5000",
"city": "Odense C",
"country_code": "dk",
},
"parcels": [{"weight_kg": 2, "description": "Router"}],
}
)
assert payload.address.country_code == "DK"
assert payload.service_codes == ["EMAIL_NT", "SMS_NT"]
def test_build_shipmondo_payload_uses_grams_and_required_parties(monkeypatch):
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_NAME", "BMC Networks")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_ADDRESS1", "Testvej 1")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_POSTAL_CODE", "4600")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_CITY", "Køge")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_COUNTRY_CODE", "DK")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_ATTENTION", "")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_ADDRESS2", "")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_EMAIL", "fragt@example.test")
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_PHONE", "70112233")
result = build_shipmondo_payload(
{
"booking_ref": "SMD-20260819-ABC12345",
"case_id": 88,
"product_code": "GLSDK_HD",
"service_codes": ["EMAIL_NT", "SMS_NT"],
"own_agreement": False,
"automatic_select_service_point": True,
"recipient_name": "Lene Jensen",
"company_name": "Kunde ApS",
"address_line1": "Vindegade 112",
"address_line2": None,
"postal_code": "5000",
"city": "Odense C",
"country_code": "DK",
"email": "lene@example.test",
"phone": "50607080",
"parcels": [
{
"weight_kg": 2.25,
"length_cm": 20,
"width_cm": 30,
"height_cm": 40,
"description": "Router",
}
],
}
)
assert result["product_code"] == "GLSDK_HD"
assert result["service_codes"] == "EMAIL_NT,SMS_NT"
assert result["automatic_select_service_point"] is True
assert result["label_format"] == "10x19_pdf"
assert result["parties"][0]["type"] == "sender"
assert result["parties"][1]["type"] == "receiver"
assert result["parties"][1]["attention"] == "Lene Jensen"
assert result["parcels"][0]["weight"] == 2250
assert result["parcels"][0]["internal_reference"] == "SMD-20260819-ABC12345-1"
def test_shipmondo_response_extractors_cover_real_response_shape():
response = {
"id": 123456,
"price": "50.00",
"parcels": [{"pkg_no": "012345678910", "label_base64": "UERG"}],
}
assert extract_tracking_number(response) == "012345678910"
assert extract_price(response) == (50.0, "DKK")
assert extract_label_base64(response) == "UERG"
def test_case_shipping_ui_supports_both_providers():
template = (ROOT / "app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert 'value="fedex"' in template
assert 'value="shipmondo"' in template
assert "/api/v1/shipmondo/products" in template
assert "shippingSelectedBooking.provider" in template
assert "Promise.allSettled" in template
def test_shipmondo_configuration_is_safe_by_default():
assert settings.SHIPMONDO_API_BASE_URL.endswith("/api/public/v3")
assert isinstance(settings.SHIPMONDO_READ_ONLY, bool)
assert isinstance(settings.SHIPMONDO_DRY_RUN, bool)