Tidskø til afregning
Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.
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 %} -
Hub-created, non-billed time entries. Opretter kun lokale ordrer.
-| - | ID | -Customer | -Date | -Case | -Hours | -Status | -Billable | -Method | -Hours edit | -Description | -Actions | -
|---|---|---|---|---|---|---|---|---|---|---|---|
| Loading... | -|||||||||||
Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.