From d0f27b78d524e19e65d55a786989c14b91b5b91d Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 25 Aug 2026 01:09:45 +0200 Subject: [PATCH] release: v2.6.0 --- .env.example | 24 + .env.prod.example | 22 + MDfile/RELEASE_NOTES_v2.6.0.md | 34 + VERSION | 2 +- app/core/config.py | 18 + app/economy/backend/router.py | 556 ++++++++++-- app/economy/frontend/time_queue.html | 561 ++---------- app/modules/sag/backend/router.py | 255 +++++- app/modules/sag/templates/detail_v3.html | 830 +++++++++++++++--- app/modules/shipmondo/__init__.py | 1 + app/modules/shipmondo/backend/__init__.py | 1 + app/modules/shipmondo/backend/api_client.py | 75 ++ app/modules/shipmondo/backend/router.py | 66 ++ app/modules/shipmondo/backend/service.py | 388 ++++++++ app/modules/shipmondo/models/__init__.py | 1 + app/modules/shipmondo/models/schemas.py | 106 +++ app/products/backend/router.py | 144 ++- app/products/frontend/list.html | 456 ++++++---- app/shared/frontend/base.html | 6 +- docker-compose.yml | 16 + main.py | 2 + migrations/1017_shipmondo_shipments.sql | 74 ++ .../1018_economy_time_queue_settlement.sql | 17 + static/js/bottom-bar.js | 82 ++ tests/test_economy_time_queue.py | 35 + tests/test_products_apigateway.py | 95 ++ tests/test_sag_module.py | 23 + tests/test_shipmondo_integration.py | 113 +++ 28 files changed, 3130 insertions(+), 873 deletions(-) create mode 100644 MDfile/RELEASE_NOTES_v2.6.0.md create mode 100644 app/modules/shipmondo/__init__.py create mode 100644 app/modules/shipmondo/backend/__init__.py create mode 100644 app/modules/shipmondo/backend/api_client.py create mode 100644 app/modules/shipmondo/backend/router.py create mode 100644 app/modules/shipmondo/backend/service.py create mode 100644 app/modules/shipmondo/models/__init__.py create mode 100644 app/modules/shipmondo/models/schemas.py create mode 100644 migrations/1017_shipmondo_shipments.sql create mode 100644 migrations/1018_economy_time_queue_settlement.sql create mode 100644 tests/test_economy_time_queue.py create mode 100644 tests/test_products_apigateway.py create mode 100644 tests/test_shipmondo_integration.py diff --git a/.env.example b/.env.example index d29325e..a27c186 100644 --- a/.env.example +++ b/.env.example @@ -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) # ===================================================== diff --git a/.env.prod.example b/.env.prod.example index 0cbcb18..d7e7e6d 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -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) # ===================================================== diff --git a/MDfile/RELEASE_NOTES_v2.6.0.md b/MDfile/RELEASE_NOTES_v2.6.0.md new file mode 100644 index 0000000..d027856 --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.6.0.md @@ -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. diff --git a/VERSION b/VERSION index 73462a5..e70b452 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 +2.6.0 diff --git a/app/core/config.py b/app/core/config.py index 902728d..4d63a89 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/economy/backend/router.py b/app/economy/backend/router.py index ce98d60..a83d513 100644 --- a/app/economy/backend/router.py +++ b/app/economy/backend/router.py @@ -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) diff --git a/app/economy/frontend/time_queue.html b/app/economy/frontend/time_queue.html index 5d05509..f2bf720 100644 --- a/app/economy/frontend/time_queue.html +++ b/app/economy/frontend/time_queue.html @@ -1,510 +1,65 @@ {% extends "shared/frontend/base.html" %} - -{% block title %}Economy Time Queue{% endblock %} - +{% block title %}Tidskø til afregning{% endblock %} {% block content %} -
-
-
-

Economy Time Queue

-

Hub-created, non-billed time entries. Opretter kun lokale ordrer.

-
-
- 0 selected - - - -
-
- -
-
-
- - - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - -
-
-
-
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - -
IDCustomerDateCaseHoursStatusBillableMethodHours editDescriptionActions
Loading...
-
-
-
-
- + +
+
Økonomi

Tidskø til afregning

Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.

+
+
Registreringer
Timer i alt
Klar til faktura
Udvalgt
0
Intet afregnes uden forhåndsvisning.
+
+

Afregning for valgte tidsregistreringer

Vælg type og eventuelt klippekort eller aftale.
0 valgt
Vælg tidsregistreringer for at se kundens klippekort.
+
Forhåndsvisning af afregning
Kontrollér beløb og fejl før afregningen gennemføres.
+ +
+
0 tidsregistreringer valgtVælg forhåndsvisning før afregning.
+
{% endblock %} diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 7834eab..1c2f8c8 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -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: diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index ccc115c..008c937 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -3033,8 +3033,8 @@ } .case-add-side-backdrop.open { - opacity: 0; - pointer-events: none; + opacity: 1; + pointer-events: auto; } .case-add-side-panel { @@ -3358,6 +3358,115 @@ text-underline-offset: 2px; } + /* Focus header: identity → title → context, with one clear next action. */ + .case-header-main { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 420px); + align-items: center; + gap: 2rem; + padding: 0.7rem 0 0.95rem; + } + + .case-header-copy { min-width: 0; } + + .case-header-identity { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; + margin-bottom: 0.42rem; + color: rgba(255,255,255,0.72); + } + + .case-header-id, + .case-header-status, + .case-header-type { + display: inline-flex; + align-items: center; + gap: 0.3rem; + min-height: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + font-size: 0.82rem; + font-weight: 650; + line-height: 1; + } + + .case-header-id { + padding: 0.28rem 0.52rem; + border-radius: 0.4rem; + background: rgba(255,255,255,0.13); + color: #fff; + font-variant-numeric: tabular-nums; + } + .case-header-status { + padding: 0.28rem 0.52rem; + border-radius: 0.4rem; + background: rgba(251,191,36,0.18); + color: #fde68a; + } + .case-header-status.is-closed { background: rgba(16,185,129,0.18); color: #a7f3d0; } + .case-header-status i { font-size: 0.43rem; } + .case-header-type { + padding: 0.28rem 0.52rem; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 0.4rem; + color: rgba(255,255,255,0.9); + } + + #sag-titel-text { + font-size: clamp(1.7rem, 2.6vw, 2.45rem); + line-height: 1.12; + letter-spacing: -0.045em; + overflow-wrap: anywhere; + } + + .case-header-meta-line { + margin-top: 0.55rem; + max-width: 100%; + font-size: 0.88rem !important; + color: rgba(255,255,255,0.8) !important; + } + + .case-header-todo { + min-width: 0; + padding: 0.8rem 0.95rem; + border: 1px solid rgba(255,255,255,0.28); + border-radius: 12px; + background: rgba(255,255,255,0.11); + box-shadow: 0 8px 20px rgba(0,0,0,0.12); + } + + .case-header-todo-label { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.25rem; + color: rgba(255,255,255,0.72); + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + } + + .case-header-todo #topbarNextTodoValue { + display: block; + overflow: hidden; + color: #fff; + font-size: 1rem !important; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + } + + .case-header-todo #topbarNextTodoMeta { + margin-top: 0.22rem; + color: rgba(255,255,255,0.72) !important; + font-size: 0.78rem; + } + .header-top-row { margin-top: 0.42rem; } @@ -3499,6 +3608,14 @@ font-size: clamp(1.45rem, 5vw, 1.9rem); } + .case-header-main { + grid-template-columns: 1fr; + gap: 0.75rem; + padding-bottom: 0.7rem; + } + + .case-header-todo { width: 100%; } + .case-header-meta-line { font-size: 0.9rem !important; gap: 0.35rem; @@ -3578,60 +3695,49 @@
-
-
- #{{ case.id }} - - {{ case.status|capitalize if case.status else 'Åben' }} - - {% set tkey = (case.template_key or case.type or 'ticket')|lower %} - {% set type_icons = {'ticket': 'bi-ticket-perforated', 'pipeline': 'bi-graph-up-arrow', 'opgave': 'bi-puzzle', 'ordre': 'bi-receipt', 'projekt': 'bi-folder2-open', 'service': 'bi-tools', 'abonnement': 'bi-arrow-repeat'} %} - {% set type_labels = {'ticket': 'Ticket', 'pipeline': 'Pipeline', 'opgave': 'Opgave', 'ordre': 'Ordre', 'projekt': 'Projekt', 'service': 'Service', 'abonnement': 'Abonnement'} %} - {% set type_colors = {'ticket': '#6366f1', 'pipeline': '#0ea5e9', 'opgave': '#f59e0b', 'ordre': '#10b981', 'projekt': '#8b5cf6', 'service': '#ef4444', 'abonnement': '#0d9488'} %} - {% set tcolor = type_colors.get(tkey, '#0f4c75') %} - {% set ticon = type_icons.get(tkey, 'bi-card-text') %} - {% set tlabel = type_labels.get(tkey, tkey|capitalize) %} - - {{ tlabel }} - -
-
-
- Næste todo - Henter næste todo... - - + {% set tkey = (case.template_key or case.type or 'ticket')|lower %} + {% set type_icons = {'ticket': 'bi-ticket-perforated', 'pipeline': 'bi-graph-up-arrow', 'opgave': 'bi-puzzle', 'ordre': 'bi-receipt', 'projekt': 'bi-folder2-open', 'service': 'bi-tools', 'abonnement': 'bi-arrow-repeat'} %} + {% set type_labels = {'ticket': 'Ticket', 'pipeline': 'Pipeline', 'opgave': 'Opgave', 'ordre': 'Ordre', 'projekt': 'Projekt', 'service': 'Service', 'abonnement': 'Abonnement'} %} + {% set type_colors = {'ticket': '#6366f1', 'pipeline': '#0ea5e9', 'opgave': '#f59e0b', 'ordre': '#10b981', 'projekt': '#8b5cf6', 'service': '#ef4444', 'abonnement': '#0d9488'} %} + {% set tcolor = type_colors.get(tkey, '#0f4c75') %} + {% set ticon = type_icons.get(tkey, 'bi-card-text') %} + {% set tlabel = type_labels.get(tkey, tkey|capitalize) %} +
+
+
+ #{{ case.id }} + {{ case.status|capitalize if case.status else 'Åben' }} + {{ tlabel }}
- -
-
- -
-
-

{{ case.titel }}

- -
-
- -
- - +
+

{{ case.titel }}

+
-
-
-

- {{ customer.name if customer else 'Ingen kunde valgt' }} +

+ +
+
+

+ {{ customer.name if customer else 'Ingen kunde valgt' }} {% if customer and customer.is_vendor %} Leverandør {% endif %} - | - {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }} - | - {{ customer.department if customer and customer.department else 'Ingen afdeling' }} - | - Oprettet: {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }} -

+ + {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }} + + {{ customer.department if customer and customer.department else 'Ingen afdeling' }} + + Oprettet {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }} +

+
+ +
@@ -8268,9 +8374,14 @@
Salgslinjer
- - +
+ + +
@@ -8302,7 +8413,7 @@
Indkøbslinjer
-
@@ -9076,11 +9187,19 @@
-
Book FedEx fragt
+
Book fragt
Draft → Bekræft → Book
+
+ + +
Vælg den udbyder, der skal booke og levere labelen.
+
-
+
+
+ + +
Produkter hentes direkte fra jeres Shipmondo-konto.
+
@@ -9115,7 +9241,7 @@
-
+
@@ -9124,6 +9250,30 @@
+
+ + +
+
+ + +
+
+
+
Shipmondo-services
+
Vælg først et fragtprodukt.
+
+
+ + +
+
+ + +
+
+
+
@@ -9203,6 +9353,10 @@ Feltændringer + 0
@@ -10528,6 +10682,10 @@ if ((event?.event_type || '') === 'reminder') return 'bi-bell'; if ((event?.event_type || '') === 'time') return 'bi-clock-history'; if ((event?.event_type || '') === 'field_change') return 'bi-sliders2'; + if ((event?.event_type || '') === 'case') return 'bi-folder-plus'; + if ((event?.event_type || '') === 'tag') return 'bi-tag'; + if ((event?.event_type || '') === 'todo') return 'bi-check2-square'; + if ((event?.event_type || '') === 'relation') return 'bi-diagram-3'; if ((event?.source || '') === 'email') return 'bi-envelope'; if ((event?.source || '') === 'sms') return 'bi-chat-dots'; if ((event?.source || '') === 'call') return 'bi-telephone'; @@ -10542,7 +10700,8 @@ month: '2-digit', year: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + second: '2-digit' }); } @@ -10556,6 +10715,7 @@ const includeReminders = document.getElementById('historyFilterReminders')?.checked !== false; const includeTime = document.getElementById('historyFilterTime')?.checked !== false; const includeFieldChanges = document.getElementById('historyFilterFieldChanges')?.checked !== false; + const includeCaseActivity = document.getElementById('historyFilterCaseActivity')?.checked !== false; const filtered = (caseHistoryTimelineCache || []).filter((event) => { if (event.event_type === 'comment' && !includeComments) return false; @@ -10563,6 +10723,7 @@ if (event.event_type === 'reminder' && !includeReminders) return false; if (event.event_type === 'time' && !includeTime) return false; if (event.event_type === 'field_change' && !includeFieldChanges) return false; + if (['case', 'tag', 'todo', 'relation'].includes(event.event_type) && !includeCaseActivity) return false; if (!search) return true; @@ -10600,7 +10761,8 @@
- ${ts} + Hvornår: ${ts} + Hvem: ${_historyEscape(event.forfatter || 'System')} ${sourceBadge} ${subtypeBadge} ${caseMeta ? `${caseMeta}` : ''} @@ -10662,7 +10824,7 @@ searchInput.addEventListener('input', renderCaseHistoryTimeline); } - ['historyFilterComments', 'historyFilterDescription', 'historyFilterReminders', 'historyFilterTime', 'historyFilterFieldChanges'].forEach((id) => { + ['historyFilterComments', 'historyFilterDescription', 'historyFilterReminders', 'historyFilterTime', 'historyFilterFieldChanges', 'historyFilterCaseActivity'].forEach((id) => { const el = document.getElementById(id); if (el) el.addEventListener('change', renderCaseHistoryTimeline); }); @@ -10683,6 +10845,184 @@ } let saleItemsCache = []; + let selectedGatewayProduct = null; + + function escapeSaleText(value) { + const element = document.createElement('div'); + element.textContent = String(value ?? ''); + return element.innerHTML; + } + + function gatewayProductReference(product) { + return product.ean || product.sku || product.supplier_sku || ''; + } + + function gatewayProductPrice(product) { + const price = Number(product.price ?? product.supplier_price ?? product.net_price ?? 0); + return Number.isFinite(price) ? price : 0; + } + + function openProductLookupModal() { + selectedGatewayProduct = null; + const search = document.getElementById('gatewayProductSearch'); + const results = document.getElementById('gatewayProductResults'); + if (search) search.value = ''; + if (results) results.innerHTML = '
Søg på varenavn, SKU eller EAN i lokalt katalog og hos grossister.
'; + new bootstrap.Modal(document.getElementById('gatewayProductModal')).show(); + window.setTimeout(() => search?.focus(), 150); + } + + async function searchGatewayProducts() { + const query = document.getElementById('gatewayProductSearch')?.value.trim() || ''; + const results = document.getElementById('gatewayProductResults'); + if (query.length < 2) { + results.innerHTML = '
Skriv mindst 2 tegn for at søge.
'; + return; + } + results.innerHTML = '
Søger i grossistkataloget...
'; + try { + const [localResult, gatewayResult] = await Promise.allSettled([ + fetch(`/api/v1/products?q=${encodeURIComponent(query)}&status=active`).then(async (res) => { + const data = await res.json().catch(() => ([])); + if (!res.ok) throw new Error(data.detail || 'Kunne ikke søge i lokalt produktkatalog'); + return Array.isArray(data) ? data : []; + }), + fetch(`/api/v1/products/apigateway/search?q=${encodeURIComponent(query)}&per_page=12`).then(async (res) => { + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.detail || 'Kunne ikke søge i API Gateway'); + return Array.isArray(data.products) ? data.products : []; + }) + ]); + const localProducts = localResult.status === 'fulfilled' + ? localResult.value.map((product) => ({ + ...product, + product_name: product.name || product.product_name, + price: product.supplier_price ?? product.price, + sales_price: product.sales_price, + source: 'local' + })) + : []; + const gatewayProducts = gatewayResult.status === 'fulfilled' + ? gatewayResult.value.map((product) => ({ ...product, source: 'gateway' })) + : []; + const products = [...localProducts, ...gatewayProducts]; + if (!products.length) { + results.innerHTML = '
Ingen varer fundet i hverken lokalt katalog eller grossistkataloget.
'; + return; + } + results.innerHTML = products.map((product, index) => { + const name = escapeSaleText(product.product_name || product.name || 'Ukendt vare'); + const sku = escapeSaleText(product.sku || '-'); + const ean = escapeSaleText(product.ean || '-'); + const supplier = escapeSaleText(product.supplier_name || product.supplier_code || 'Ukendt grossist'); + const stock = product.stock_qty ?? product.supplier_stock ?? '-'; + const price = formatCurrency(gatewayProductPrice(product)); + const salePrice = product.sales_price != null ? formatCurrency(product.sales_price) : null; + const source = product.source === 'local' + ? 'Lokalt katalog' + : 'Grossistkatalog'; + return ``; + }).join(''); + window.gatewaySearchResults = products; + } catch (error) { + console.error(error); + results.innerHTML = `
${escapeSaleText(error.message || 'Kunne ikke søge i grossistkataloget')}
`; + } + } + + function selectGatewayProduct(index) { + selectedGatewayProduct = (window.gatewaySearchResults || [])[index] || null; + if (!selectedGatewayProduct) return; + bootstrap.Modal.getInstance(document.getElementById('gatewayProductModal'))?.hide(); + openGatewayProductLineModal(selectedGatewayProduct); + } + + function openGatewayProductLineModal(product) { + const cost = gatewayProductPrice(product); + const title = product.product_name || product.name || 'Vare fra grossistkatalog'; + document.getElementById('gateway_line_title').textContent = title; + document.getElementById('gateway_line_meta').textContent = [ + product.supplier_name || product.supplier_code, + product.sku ? `SKU ${product.sku}` : '', + product.ean ? `EAN ${product.ean}` : '', + product.stock_qty != null ? `Lager ${product.stock_qty}` : '' + ].filter(Boolean).join(' · '); + document.getElementById('gateway_line_quantity').value = 1; + document.getElementById('gateway_line_purchase_price').value = cost ? cost.toFixed(2) : ''; + document.getElementById('gateway_line_sale_price').value = product.sales_price != null ? Number(product.sales_price).toFixed(2) : ''; + document.getElementById('gateway_line_add_purchase').checked = true; + document.getElementById('gateway_line_add_sale').checked = true; + document.getElementById('gateway_line_status').value = 'draft'; + updateGatewayLineTotals(); + new bootstrap.Modal(document.getElementById('gatewayProductLineModal')).show(); + } + + function updateGatewayLineTotals() { + const quantity = Number(document.getElementById('gateway_line_quantity')?.value || 0); + const purchase = Number(document.getElementById('gateway_line_purchase_price')?.value || 0); + const saleInput = document.getElementById('gateway_line_sale_price'); + const sale = Number(saleInput?.value || 0); + document.getElementById('gateway_line_purchase_total').textContent = formatCurrency(quantity * purchase); + document.getElementById('gateway_line_sale_total').textContent = saleInput?.value === '' ? 'Ikke sat' : formatCurrency(quantity * sale); + } + + async function saveGatewayProductLines() { + const product = selectedGatewayProduct; + if (!product) return; + const quantity = Number(document.getElementById('gateway_line_quantity').value || 0); + const purchasePrice = Number(document.getElementById('gateway_line_purchase_price').value || 0); + const salePriceRaw = document.getElementById('gateway_line_sale_price').value; + const salePrice = salePriceRaw === '' ? null : Number(salePriceRaw); + const addPurchase = document.getElementById('gateway_line_add_purchase').checked; + const addSale = document.getElementById('gateway_line_add_sale').checked; + const status = document.getElementById('gateway_line_status').value || 'draft'; + if (!quantity || quantity <= 0 || (!addPurchase && !addSale)) { + alert('Vælg mindst én linjetype og et antal større end 0.'); + return; + } + if ((addPurchase && purchasePrice < 0) || (addSale && salePrice !== null && salePrice < 0)) { + alert('Priser må ikke være negative.'); + return; + } + const description = String(product.product_name || product.name || '').trim(); + const externalRef = gatewayProductReference(product) || null; + const common = { + description, + quantity, + unit: 'stk', + currency: product.currency || 'DKK', + status, + line_date: new Date().toISOString().slice(0, 10), + external_ref: externalRef, + product_id: product.source === 'local' ? product.id : null + }; + const lines = []; + if (addPurchase) lines.push({ ...common, type: 'purchase', unit_price: purchasePrice, amount: quantity * purchasePrice, purchase_purpose: addSale ? 'salg' : null }); + if (addSale) lines.push({ ...common, type: 'sale', unit_price: salePrice, amount: salePrice === null ? 0 : quantity * salePrice }); + const saveButton = document.getElementById('gatewayProductLineSave'); + saveButton.disabled = true; + try { + const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items/batch`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lines }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.detail || 'Kunne ikke oprette varelinjerne'); + bootstrap.Modal.getInstance(document.getElementById('gatewayProductLineModal'))?.hide(); + await loadVarekobSalg(); + } catch (error) { + alert(error.message || 'Kunne ikke oprette varelinjerne'); + } finally { + saveButton.disabled = false; + } + } const purchasePurposeLabels = { salg: 'Salg', @@ -12612,8 +12952,103 @@ const shippingCustomerPostal = {{ (customer.postal_code if customer and customer.postal_code else '')|tojson }}; const shippingCustomerCity = {{ (customer.city if customer and customer.city else '')|tojson }}; const shippingCustomerCountry = {{ (customer.country if customer and customer.country else 'DK')|tojson }}; - let shippingSelectedBookingRef = null; + let shippingSelectedBooking = null; let shippingAddressPresets = []; + let shipmondoProducts = []; + + function shippingEscapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function currentShippingProvider() { + return document.getElementById('shipProvider')?.value || 'fedex'; + } + + function selectedShipmondoProduct() { + const code = document.getElementById('shipProductCode')?.value || ''; + return shipmondoProducts.find((product) => String(product.code) === code) || null; + } + + function renderShipmondoServices() { + const container = document.getElementById('shipServiceCodes'); + if (!container) return; + const product = selectedShipmondoProduct(); + if (!product) { + container.innerHTML = 'Vælg først et fragtprodukt.'; + return; + } + const requiredCodes = new Set((product.required_services || []).map((service) => String(service.code || ''))); + const services = [...(product.available_services || [])]; + for (const required of product.required_services || []) { + if (!services.some((service) => String(service.code) === String(required.code))) services.push(required); + } + if (!services.length) { + container.innerHTML = 'Produktet har ingen ekstra services.'; + return; + } + container.innerHTML = services.map((service, index) => { + const code = String(service.code || ''); + const required = requiredCodes.has(code); + const note = service.note ? ` title="${shippingEscapeHtml(service.note)}"` : ''; + return ``; + }).join(''); + } + + async function loadShipmondoProducts() { + const select = document.getElementById('shipProductCode'); + const help = document.getElementById('shipProductHelp'); + if (!select || currentShippingProvider() !== 'shipmondo') return; + const countryCode = (document.getElementById('shipCountryCode')?.value || 'DK').trim().toUpperCase(); + select.disabled = true; + select.innerHTML = ''; + if (help) help.textContent = `Henter produkter til ${countryCode} fra Shipmondo...`; + try { + const response = await fetch(`/api/v1/shipmondo/products?country_code=${encodeURIComponent(countryCode)}`); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + shipmondoProducts = Array.isArray(data.items) ? data.items : []; + select.innerHTML = '' + shipmondoProducts.map((product) => { + const carrier = product.carrier?.name || product.carrier?.code || ''; + const weights = Array.isArray(product.weight_intervals) ? product.weight_intervals : []; + const maxWeight = weights.reduce((max, item) => Math.max(max, Number(item.to_weight || 0)), 0); + const weightText = maxWeight ? ` · op til ${(maxWeight / 1000).toLocaleString('da-DK')} kg` : ''; + return ``; + }).join(''); + if (help) help.textContent = shipmondoProducts.length + ? `${shipmondoProducts.length} tilgængelige produkter. Påkrævede services vælges automatisk.` + : `Ingen Shipmondo-produkter fundet til ${countryCode}.`; + } catch (error) { + shipmondoProducts = []; + select.innerHTML = ''; + if (help) help.textContent = error.message; + } finally { + select.disabled = false; + renderShipmondoServices(); + } + } + + function updateShippingProviderUi() { + const provider = currentShippingProvider(); + document.querySelectorAll('.ship-fedex-field').forEach((element) => element.classList.toggle('d-none', provider !== 'fedex')); + document.querySelectorAll('.ship-shipmondo-field').forEach((element) => element.classList.toggle('d-none', provider !== 'shipmondo')); + const pickup = document.getElementById('shipPickupAt'); + const product = document.getElementById('shipProductCode'); + if (pickup) pickup.required = provider === 'fedex'; + if (product) product.required = provider === 'shipmondo'; + shippingSelectedBooking = null; + const submitBtn = document.getElementById('shipSubmitBtn'); + if (submitBtn) submitBtn.disabled = true; + setShippingNotice(''); + if (provider === 'shipmondo') loadShipmondoProducts(); + } function _shippingFirstNonEmpty(...values) { for (const value of values) { @@ -12840,10 +13275,21 @@ list.innerHTML = '
Henter forsendelser...
'; try { - const res = await fetch(`/api/v1/fedex/bookings?case_id=${shippingCaseId}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const payload = await res.json(); - const items = Array.isArray(payload?.items) ? payload.items : []; + const providers = ['fedex', 'shipmondo']; + const results = await Promise.allSettled(providers.map(async (provider) => { + const response = await fetch(`/api/v1/${provider}/bookings?case_id=${shippingCaseId}`); + if (!response.ok) throw new Error(`${provider}: HTTP ${response.status}`); + const payload = await response.json(); + return (Array.isArray(payload?.items) ? payload.items : []).map((item) => ({ ...item, provider })); + })); + const items = results + .filter((result) => result.status === 'fulfilled') + .flatMap((result) => result.value) + .sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); + + if (!items.length && results.every((result) => result.status === 'rejected')) { + throw new Error('Ingen fragtudbydere kunne hentes'); + } if (!items.length) { list.innerHTML = '
Ingen forsendelser endnu.
'; @@ -12855,13 +13301,17 @@ list.innerHTML = items.map((item) => { const ts = item.created_at ? new Date(item.created_at).toLocaleString('da-DK') : '-'; const badge = bookingStatusBadge(item.shipment_status); + const providerLabel = item.provider === 'shipmondo' ? 'Shipmondo' : 'FedEx'; + const productLabel = item.provider === 'shipmondo' + ? (item.carrier_name || item.carrier_code || item.product_name || item.product_code || '') + : (item.service_type || ''); const hasPrice = item.total_amount != null && !Number.isNaN(Number(item.total_amount)); const priceText = hasPrice ? `${Number(item.total_amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${item.currency || 'DKK'}` : null; const labelUrl = String(item.label_url || '').trim(); const trackingNumber = String(item.tracking_number || '').trim(); - const trackingUrl = String(item.tracking_url || (trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim(); + const trackingUrl = String(item.tracking_url || (item.provider === 'fedex' && trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim(); const labelAction = labelUrl ? `Åbn label` : ''; @@ -12872,13 +13322,14 @@
-
${item.booking_ref}
-
${item.recipient_name || '-'} · ${item.city || '-'} (${item.country_code || '-'})
+
${providerLabel}${shippingEscapeHtml(item.booking_ref)}
+
${shippingEscapeHtml(item.recipient_name || '-')} · ${shippingEscapeHtml(item.city || '-')} (${shippingEscapeHtml(item.country_code || '-')})
+ ${productLabel ? `
${shippingEscapeHtml(productLabel)}
` : ''}
${ts}
- ${item.shipment_status || 'draft'} + ${shippingEscapeHtml(item.shipment_status || 'draft')}
- ${item.tracking_number ? `
Tracking: ${item.tracking_number}
` : ''} + ${item.tracking_number ? `
Tracking: ${shippingEscapeHtml(item.tracking_number)}
` : ''} ${priceText ? `
Pris: ${priceText}
` : ''}
${labelAction}${trackingAction}
@@ -12900,42 +13351,67 @@ async function createShippingDraft(event) { event.preventDefault(); - - const pickupAtRaw = document.getElementById('shipPickupAt')?.value; - if (!pickupAtRaw) { - setShippingNotice('Vælg afhentningstidspunkt.', 'danger'); - return; - } - - const pickupStart = new Date(pickupAtRaw); - const pickupEnd = new Date(pickupStart.getTime() + 60 * 60 * 1000); - const payload = { + const provider = currentShippingProvider(); + const commonAddress = { + recipient_name: document.getElementById('shipRecipientName')?.value?.trim() || '', + company_name: shippingCustomerName || null, + address_line1: document.getElementById('shipAddressLine1')?.value?.trim() || '', + postal_code: document.getElementById('shipPostalCode')?.value?.trim() || '', + city: document.getElementById('shipCity')?.value?.trim() || '', + country_code: (document.getElementById('shipCountryCode')?.value?.trim() || 'DK').toUpperCase(), + email: document.getElementById('shipEmail')?.value?.trim() || null, + phone: document.getElementById('shipPhone')?.value?.trim() || null, + }; + const commonPackages = [{ + weight_kg: Number(document.getElementById('shipWeightKg')?.value || 0), + length_cm: Number(document.getElementById('shipLengthCm')?.value || 0), + width_cm: Number(document.getElementById('shipWidthCm')?.value || 0), + height_cm: Number(document.getElementById('shipHeightCm')?.value || 0), + description: document.getElementById('shipDescription')?.value?.trim() || 'Pakke', + }]; + let payload = { case_id: shippingCaseId, customer_id: shippingCustomerId, contact_id: shippingContactId, - service_type: document.getElementById('shipServiceType')?.value || 'PRIORITY', - pickup_window_start: pickupStart.toISOString(), - pickup_window_end: pickupEnd.toISOString(), - address: { - recipient_name: document.getElementById('shipRecipientName')?.value?.trim() || '', - address_line1: document.getElementById('shipAddressLine1')?.value?.trim() || '', - postal_code: document.getElementById('shipPostalCode')?.value?.trim() || '', - city: document.getElementById('shipCity')?.value?.trim() || '', - country_code: (document.getElementById('shipCountryCode')?.value?.trim() || 'DK').toUpperCase(), - }, - packages: [ - { - weight_kg: Number(document.getElementById('shipWeightKg')?.value || 0), - length_cm: Number(document.getElementById('shipLengthCm')?.value || 0), - width_cm: Number(document.getElementById('shipWidthCm')?.value || 0), - height_cm: Number(document.getElementById('shipHeightCm')?.value || 0), - description: document.getElementById('shipDescription')?.value?.trim() || 'Pakke', - } - ] + address: commonAddress, }; + if (provider === 'shipmondo') { + const product = selectedShipmondoProduct(); + if (!product) { + setShippingNotice('Vælg et Shipmondo-fragtprodukt.', 'danger'); + return; + } + payload = { + ...payload, + product_code: String(product.code || ''), + product_name: product.name || null, + carrier_code: product.carrier?.code || null, + carrier_name: product.carrier?.name || null, + service_codes: Array.from(document.querySelectorAll('.ship-service-code:checked')).map((input) => input.value), + own_agreement: Boolean(document.getElementById('shipOwnAgreement')?.checked), + automatic_select_service_point: Boolean(document.getElementById('shipAutoServicePoint')?.checked), + parcels: commonPackages, + }; + } else { + const pickupAtRaw = document.getElementById('shipPickupAt')?.value; + if (!pickupAtRaw) { + setShippingNotice('Vælg afhentningstidspunkt.', 'danger'); + return; + } + const pickupStart = new Date(pickupAtRaw); + const pickupEnd = new Date(pickupStart.getTime() + 60 * 60 * 1000); + payload = { + ...payload, + service_type: document.getElementById('shipServiceType')?.value || 'PRIORITY', + pickup_window_start: pickupStart.toISOString(), + pickup_window_end: pickupEnd.toISOString(), + packages: commonPackages, + }; + } + try { - const res = await fetch('/api/v1/fedex/bookings', { + const res = await fetch(`/api/v1/${provider}/bookings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -12945,10 +13421,11 @@ throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); - shippingSelectedBookingRef = data.booking_ref; + shippingSelectedBooking = { provider, bookingRef: data.booking_ref }; const submitBtn = document.getElementById('shipSubmitBtn'); if (submitBtn) submitBtn.disabled = false; - setShippingNotice(`Draft oprettet: ${data.booking_ref}. Tryk "Bekræft & book" for at sende til FedEx.`, 'success'); + const providerLabel = provider === 'shipmondo' ? 'Shipmondo' : 'FedEx'; + setShippingNotice(`Draft oprettet: ${data.booking_ref}. Tryk "Bekræft & book" for at sende til ${providerLabel}.`, 'success'); await loadCaseShippingTab(true); } catch (error) { console.error('Create shipping draft failed:', error); @@ -12957,14 +13434,15 @@ } async function submitShippingBooking() { - if (!shippingSelectedBookingRef) { + if (!shippingSelectedBooking) { setShippingNotice('Opret først en draft for at kunne booke.', 'warning'); return; } - if (!confirm(`Book forsendelse ${shippingSelectedBookingRef} hos FedEx?`)) return; + const providerLabel = shippingSelectedBooking.provider === 'shipmondo' ? 'Shipmondo' : 'FedEx'; + if (!confirm(`Book forsendelse ${shippingSelectedBooking.bookingRef} hos ${providerLabel}?`)) return; try { - const res = await fetch(`/api/v1/fedex/bookings/${encodeURIComponent(shippingSelectedBookingRef)}/submit`, { + const res = await fetch(`/api/v1/${shippingSelectedBooking.provider}/bookings/${encodeURIComponent(shippingSelectedBooking.bookingRef)}/submit`, { method: 'POST' }); if (!res.ok) { @@ -13004,8 +13482,20 @@ submitBtn.addEventListener('click', submitShippingBooking); } + const providerSelect = document.getElementById('shipProvider'); + if (providerSelect) providerSelect.addEventListener('change', updateShippingProviderUi); + + const productSelect = document.getElementById('shipProductCode'); + if (productSelect) productSelect.addEventListener('change', renderShipmondoServices); + + const countryInput = document.getElementById('shipCountryCode'); + if (countryInput) countryInput.addEventListener('change', () => { + if (currentShippingProvider() === 'shipmondo') loadShipmondoProducts(); + }); + loadShippingAddressPresets(); initShippingPickupInput(); + updateShippingProviderUi(); // Fallback preload so list is populated even if tab click hook is skipped. loadCaseShippingTab(true); @@ -13102,6 +13592,89 @@
+ + + + + @@ -234,8 +360,8 @@ -