from app.services.invoice_fee_service import with_invoice_fee, invoice_fee_line import logging from collections import defaultdict from datetime import date from decimal import Decimal import json from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, Field from app.core.config import settings from app.core.database import execute_insert, execute_query, execute_query_single, execute_update logger = logging.getLogger(__name__) router = APIRouter(prefix="/economy", tags=["Economy"]) class BulkIdsRequest(BaseModel): ids: List[int] = Field(..., min_length=1) class BulkUpdateRequest(BaseModel): ids: List[int] = Field(..., min_length=1) description: Optional[str] = None prepaid_card_id: Optional[int] = Field(None, gt=0) fixed_price_agreement_id: Optional[int] = Field(None, gt=0) original_hours: Optional[float] = Field(None, gt=0) billable: Optional[bool] = None billing_method: Optional[str] = None class BulkSoftDeleteRequest(BaseModel): ids: List[int] = Field(..., min_length=1) reason: Optional[str] = "Soft deleted from economy queue" class BulkApproveRequest(BaseModel): ids: List[int] = Field(..., min_length=1) billable: Optional[bool] = None billing_method: Optional[str] = None class BulkPrepaidRequest(BaseModel): ids: List[int] = Field(..., min_length=1) prepaid_card_id: int = Field(..., gt=0) 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) line_mode: str = Field(default="separate", pattern="^(separate|combined)$") 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: raise HTTPException(status_code=400, detail="No valid ids provided") 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), status: Optional[str] = Query(None), case_state: Optional[str] = Query(None), billable: Optional[bool] = Query(None), q: Optional[str] = Query(None), limit: int = Query(500, ge=1, le=2000), scope: Optional[str] = Query(None, pattern="^(active|history|all)$"), request: Request = None, ): """List non-billed Hub-created time entries for the economy queue.""" try: conditions = [ "t.vtiger_id IS NULL", "t.billed_via_thehub_id IS NULL", "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", ] if scope in {'active', 'history', 'all'}: conditions = ["t.vtiger_id IS NULL", "t.status <> 'rejected'"] if scope == 'active': conditions += ["t.billed_via_thehub_id IS NULL", "t.economy_order_draft_id IS NULL", "t.status <> 'billed'", "t.economy_settled_at IS NULL"] elif scope == 'history': conditions.append("(t.status IN ('approved','billed') OR t.economy_order_draft_id IS NOT NULL OR t.billed_via_thehub_id IS NOT NULL OR t.economy_settled_at IS NOT NULL)") 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("COALESCE(s.customer_id, effective_customer.hub_customer_id) = %s") params.append(customer_id) if status: conditions.append("t.status = %s") params.append(status) if case_state == "closed": conditions.append("LOWER(TRIM(COALESCE(s.status, ''))) IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')") elif case_state == "open": conditions.append("s.id IS NOT NULL AND LOWER(TRIM(COALESCE(s.status, ''))) NOT IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')") elif case_state == "no_case": conditions.append("s.id IS NULL") elif case_state: raise HTTPException(status_code=400, detail="Ugyldigt sagsfilter") if billable is not None: conditions.append("COALESCE(t.billable, true) = %s") params.append(billable) if q: conditions.append( "(" "COALESCE(t.description, '') ILIKE %s OR " "COALESCE(case_customer.name, effective_customer.name, '') ILIKE %s OR " "COALESCE(c.title, s.titel, '') ILIKE %s" ")" ) like = f"%{q}%" params.extend([like, like, like]) where_sql = " AND ".join(conditions) query = f""" SELECT t.id, 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, 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.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, '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, t.workflow_revision, t.economy_order_draft_id, t.economy_settled_at, t.billed_via_thehub_id, draft.sync_status AS order_sync_status, draft.economic_invoice_number FROM tmodule_times t LEFT JOIN ordre_drafts draft ON draft.id = t.economy_order_draft_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 """ params.append(limit) rows = execute_query(query, tuple(params)) _attach_case_tags(rows) if settings.WORKFLOWS_ENABLED: _attach_workflow_decisions(rows) if scope in {"active", "history", "all"}: from app.core.auth_dependencies import get_current_user actor = await _queue_actor(request) if request is not None else {} permissions = actor.get('permissions') or [] can_override = actor.get('is_superadmin') or all(p in permissions for p in ('workflows.override','workflows.decisions.view')) for row in rows: row.update(_queue_lifecycle(row)) row['entry_fingerprint'] = _queue_review_fingerprint(row) row['workflow_can_override'] = bool(can_override and row['can_change_billing']) can_approve = bool(actor.get('is_superadmin') or 'billing.approve' in permissions) if row.get('workflow_active'): can_approve = can_approve and bool(actor.get('is_superadmin') or 'workflows.settle' in permissions) row['can_approve'] = bool(row['can_approve'] and can_approve) row['can_settle'] = bool(row['can_settle'] and can_approve) row['can_change_billing'] = bool(row['can_change_billing'] and (can_override if row.get('workflow_active') else actor.get('is_superadmin') or 'billing.edit' in permissions)) return {"items": rows, "count": len(rows), "workflow_enabled": settings.WORKFLOWS_ENABLED} except HTTPException: raise except Exception as e: logger.error("Failed listing economy time queue: %s", e) raise HTTPException(status_code=500, detail="Failed to list time queue") @router.get("/time-queue/customers") async def list_time_queue_customers(): """List customers that currently have queue-relevant (not billed) Hub entries.""" try: rows = execute_query( """ SELECT 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 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 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)} except Exception as e: logger.error("Failed listing time queue customers: %s", e) raise HTTPException(status_code=500, detail="Failed listing customer filter options") @router.get("/time-queue/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( 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_sql} ORDER BY remaining_hours DESC, id DESC """, tuple(params), ) return {"items": cards, "count": len(cards)} except Exception as e: logger.error("Failed listing prepaid cards: %s", e) raise HTTPException(status_code=500, detail="Failed to list prepaid cards") @router.patch("/time-queue/bulk-update") async def bulk_update_time_queue(payload: BulkUpdateRequest, request: Request = None): if {'prepaid_card_id', 'fixed_price_agreement_id'}.intersection(payload.model_fields_set): from app.core.database import atomic_transaction from app.core.auth_dependencies import require_permission actor = await _queue_actor(request) await require_permission('billing.edit')(actor) with atomic_transaction(): execute_query('SELECT id FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (_ensure_ids(payload.ids),)) return await _bulk_update_time_queue(payload) return await _bulk_update_time_queue(payload) async def _bulk_update_time_queue(payload: BulkUpdateRequest): ids = _ensure_ids(payload.ids) if payload.billing_method is not None or payload.billable is not None or {"prepaid_card_id","fixed_price_agreement_id"}.intersection(payload.model_fields_set): _protect_workflow_selection(ids) # Financial choices are frozen once the employee's registration is reviewed. financial_edit = bool({'prepaid_card_id','fixed_price_agreement_id'}.intersection(payload.model_fields_set)) if financial_edit: current = execute_query('SELECT id,status,economy_order_draft_id,economy_settled_at,billed_via_thehub_id FROM tmodule_times WHERE id=ANY(%s)', (ids,)) or [] if len(current) != len(ids) or any(row.get('status') in {'approved','billed','rejected'} or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('billed_via_thehub_id') for row in current): raise HTTPException(409, 'Afregning kan kun ændres før godkendelse') if payload.billing_method is not None and _normalise_billing_method(payload.billing_method) not in VALID_SETTLEMENT_METHODS | {'draft','operations','development'}: raise HTTPException(422, 'Ugyldig afregningsmetode') updates: List[str] = [] values: List[Any] = [] if payload.description is not None: updates.append("description = %s") values.append(payload.description) if payload.original_hours is not None: updates.append("original_hours = %s") values.append(payload.original_hours) if payload.billable is not None: updates.append("billable = %s") values.append(payload.billable) if payload.billable is False and payload.billing_method is None: updates.append("billing_method = 'internal'") if payload.billing_method is not None: updates.append("billing_method = %s") values.append(payload.billing_method) if "prepaid_card_id" in payload.model_fields_set: updates.append("prepaid_card_id = %s") values.append(payload.prepaid_card_id) if "fixed_price_agreement_id" in payload.model_fields_set: updates.append("fixed_price_agreement_id = %s") values.append(payload.fixed_price_agreement_id) if not updates: raise HTTPException(status_code=400, detail="No update fields provided") try: placeholders = ",".join(["%s"] * len(ids)) query = f""" UPDATE tmodule_times SET {", ".join(updates)} 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' {"AND status NOT IN ('approved','rejected') AND economy_settled_at IS NULL" if financial_edit else ""} RETURNING id """ if financial_edit: changed = execute_query(query, tuple(values + ids)) or [] if len(changed) != len(ids): raise HTTPException(409, "En tid blev ændret under behandlingen. Genindlæs køen.") else: execute_update(query, tuple(values + ids)) return {"success": True, "updated": len(ids)} except HTTPException: raise except Exception as e: logger.error("Failed bulk update: %s", e) raise HTTPException(status_code=500, detail="Failed bulk update") @router.post("/time-queue/bulk-soft-delete") async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest): ids = _ensure_ids(payload.ids) reason = (payload.reason or "Soft deleted from economy queue").strip() try: placeholders = ",".join(["%s"] * len(ids)) execute_update( f""" UPDATE tmodule_times SET status = 'rejected', entry_status = 'kladde', approval_note = %s, updated_at = CURRENT_TIMESTAMP 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), ) return {"success": True, "soft_deleted": len(ids)} except Exception as e: logger.error("Failed bulk soft delete: %s", e) raise HTTPException(status_code=500, detail="Failed bulk soft delete") @router.post("/time-queue/bulk-approve") async def bulk_approve_time_queue(payload: BulkApproveRequest): ids = _ensure_ids(payload.ids) _protect_workflow_selection(ids) try: set_parts = [ "status = 'approved'", "entry_status = 'godkendt'", "approved_hours = COALESCE(approved_hours, original_hours)", "approved_at = CURRENT_TIMESTAMP", "updated_at = CURRENT_TIMESTAMP", ] params: List[Any] = [] if payload.billable is not None: set_parts.append("billable = %s") params.append(payload.billable) if payload.billing_method is not None: set_parts.append("billing_method = %s") params.append(payload.billing_method) placeholders = ",".join(["%s"] * len(ids)) query = f""" UPDATE tmodule_times SET {", ".join(set_parts)} 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)) return {"success": True, "approved": len(ids)} except Exception as e: logger.error("Failed bulk approve: %s", e) raise HTTPException(status_code=500, detail="Failed bulk approve") @router.post("/time-queue/bulk-apply-prepaid") async def bulk_apply_prepaid(payload: BulkPrepaidRequest): ids = _ensure_ids(payload.ids) _protect_workflow_selection(ids) card = execute_query_single( "SELECT id FROM tticket_prepaid_cards WHERE id = %s", (payload.prepaid_card_id,), ) if not card: raise HTTPException(status_code=404, detail="Prepaid card not found") try: placeholders = ",".join(["%s"] * len(ids)) execute_update( f""" UPDATE tmodule_times SET prepaid_card_id = %s, billing_method = 'prepaid', billable = TRUE, updated_at = CURRENT_TIMESTAMP 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), ) return {"success": True, "updated": len(ids), "prepaid_card_id": payload.prepaid_card_id} except Exception as e: logger.error("Failed applying prepaid card: %s", e) raise HTTPException(status_code=500, detail="Failed applying prepaid card") def _create_order_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,), ) if not customer: raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found") hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)) grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: { "rows": [], "case_title": "Time entries", "case_id": None, "sag_id": None, }) for row in rows: group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}" grouped[group_key]["rows"].append(row) grouped[group_key]["case_title"] = row.get("case_title") or "Time entries" grouped[group_key]["case_id"] = row.get("case_id") grouped[group_key]["sag_id"] = row.get("sag_id") line_payloads: List[Dict[str, Any]] = [] total_hours = Decimal("0") for _, group in grouped.items(): qty = Decimal("0") ids: List[int] = [] latest_date = None for row in group["rows"]: qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0)) ids.append(int(row["id"])) wd = row.get("worked_date") if wd and (latest_date is None or wd > latest_date): latest_date = wd line_total = (qty * hourly_rate).quantize(Decimal("0.01")) line_payloads.append( { "description": group["case_title"], "quantity": qty, "line_total": line_total, "time_entry_ids": ids, "case_id": group["case_id"], "sag_id": group["sag_id"], "time_date": latest_date, } ) total_hours += qty fee = invoice_fee_line(customer.get("hub_customer_id")) subtotal = (total_hours * hourly_rate).quantize(Decimal("0.01")) if fee: subtotal += Decimal(str(fee["amount"])) vat_rate = Decimal("25.00") vat_amount = (subtotal * vat_rate / Decimal("100")).quantize(Decimal("0.01")) total_amount = subtotal + vat_amount order_id = execute_insert( """ INSERT INTO tmodule_orders (customer_id, hub_customer_id, order_date, total_hours, hourly_rate, subtotal, vat_rate, vat_amount, total_amount, status, created_by) VALUES (%s, %s, CURRENT_DATE, %s, %s, %s, %s, %s, %s, 'draft', %s) RETURNING id """, ( customer_id, customer.get("hub_customer_id"), total_hours, hourly_rate, subtotal, vat_rate, vat_amount, total_amount, user_id, ), ) for idx, line in enumerate(line_payloads, start=1): execute_insert( """ INSERT INTO tmodule_order_lines (order_id, case_id, sag_id, line_number, description, quantity, unit_price, line_total, time_entry_ids, case_contact, time_date, is_travel) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, FALSE) RETURNING id """, ( order_id, line["case_id"], line["sag_id"], idx, line["description"], line["quantity"], hourly_rate, line["line_total"], line["time_entry_ids"], line["time_date"], ), ) if fee: execute_insert("""INSERT INTO tmodule_order_lines (order_id, line_number, description, quantity, unit_price, line_total, time_entry_ids, product_number, product_id, source_type) VALUES (%s, %s, %s, 1, %s, %s, %s, %s, %s, 'invoice_fee') RETURNING id""", (order_id, len(line_payloads) + 1, fee['description'], fee['unit_price'], fee['amount'], [], fee['product_number'], fee['product_id'])) return int(order_id) def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int], *, line_mode: Optional[str] = None) -> 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, invoice_fee_exempt FROM customers WHERE id = %s """, (hub_customer_id,), ) if not hub_customer: raise HTTPException(status_code=404, detail=f"Kunden på sagen ({hub_customer_id}) findes ikke") 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)) special_freight_price = (hub_customer or {}).get("special_freight_price") special_freight_amount = Decimal(str(special_freight_price)) if special_freight_price is not None else Decimal("0") supplier_service_enrolled = bool((hub_customer or {}).get("supplier_service_enrolled")) standard_margin_percent = Decimal( str( (hub_customer or {}).get("standard_margin_percent") if (hub_customer or {}).get("standard_margin_percent") is not None else settings.CUSTOMER_DEFAULT_MARGIN_PERCENT ) ) base_hourly_rate = Decimal( str( (hub_customer or {}).get("standard_hourly_rate") if (hub_customer or {}).get("standard_hourly_rate") is not None else hourly_rate ) ) grouped: Dict[str, Dict[str, Any]] = defaultdict(lambda: { "rows": [], "case_title": "Time entries", "case_id": None, "sag_id": None, }) for row in rows: group_key = f"{row.get('case_id') or 0}:{row.get('sag_id') or 0}" if line_mode == "separate": group_key = str(row['id']) elif line_mode == "combined": group_key = str(row.get('workflow_product_number') or '') grouped[group_key]["rows"].append(row) grouped[group_key]["case_title"] = row.get("case_title") or "Time entries" grouped[group_key]["case_id"] = row.get("case_id") grouped[group_key]["sag_id"] = row.get("sag_id") line_payloads: List[Dict[str, Any]] = [] for _, group in grouped.items(): qty = Decimal("0") ids: List[int] = [] latest_date = None for row in group["rows"]: qty += Decimal(str(row.get("approved_hours") or row.get("original_hours") or 0)) ids.append(int(row["id"])) wd = row.get("worked_date") if wd and (latest_date is None or wd > latest_date): latest_date = wd effective_margin_percent = standard_margin_percent if standard_margin_percent >= Decimal("0") else Decimal("0") unit_price = base_hourly_rate.quantize(Decimal("0.01")) amount = (qty * unit_price).quantize(Decimal("0.01")) workflow_product_number = group['rows'][0].get('workflow_product_number') workflow_product_id = None if line_mode is not None and workflow_product_number: products = execute_query('''SELECT id FROM products WHERE deleted_at IS NULL AND status='active' AND lifecycle_status NOT IN ('inactive', 'phasing_out') AND is_active_in_economic IS DISTINCT FROM FALSE AND (economic_product_number=%s OR sku_internal=%s)''', (workflow_product_number, workflow_product_number)) or [] if len(products) != 1: raise HTTPException(409, 'Workflowets varenummer skal være knyttet til præcis én aktiv Hub-vare') workflow_product_id = int(products[0]['id']) line_payloads.append( { "line_key": f"timequeue:{ids[0] if ids else 0}:{group.get('case_id') or 0}:{group.get('sag_id') or 0}", "source_type": "timequeue", "source_id": ids[0] if ids else None, "description": group["case_title"], "quantity": float(qty), "unit_price": float(unit_price), "discount_percentage": 0, "unit": "timer", "product_id": workflow_product_id, "product_number": workflow_product_number, "economic_product_number": workflow_product_number, "selected": True, "amount": float(amount), "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, "meta": { "base_hourly_rate": float(base_hourly_rate.quantize(Decimal("0.01"))), "standard_margin_percent": float(effective_margin_percent), }, } ) if special_freight_amount > 0: line_payloads.append( { "line_key": f"freight:{hub_customer_id}", "source_type": "freight", "source_id": None, "description": "Særlig fragtpris", "quantity": 1.0, "unit_price": float(special_freight_amount.quantize(Decimal("0.01"))), "discount_percentage": 0, "unit": "stk", "product_id": None, "selected": True, "amount": float(special_freight_amount.quantize(Decimal("0.01"))), "customer_id": int(hub_customer_id), "customer_name": customer_name, "sag_id": None, "time_entry_ids": [], "time_date": None, } ) line_payloads = with_invoice_fee(line_payloads, hub_customer_id, legacy=True, customer=hub_customer) if not line_payloads: raise HTTPException(status_code=400, detail="No order lines generated from selected entries") draft_title = f"Timefaktura {customer_name} - {date.today().isoformat()}" invoice_aggregate_key = f"timequeue-customer-{hub_customer_id}" draft = execute_query_single( """ INSERT INTO ordre_drafts ( title, customer_id, lines_json, notes, layout_number, created_by_user_id, sync_status, export_status_json, invoice_aggregate_key, updated_at ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP) RETURNING id """, ( draft_title, int(hub_customer_id), json.dumps(line_payloads, ensure_ascii=False), "Genereret fra Economy Time Queue", 1, user_id, json.dumps({}, ensure_ascii=False), invoice_aggregate_key, ), ) if not draft: raise HTTPException(status_code=500, detail="Failed creating ordre draft") return int(draft["id"]) def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]: """Resolve the actual Hub customer that must be invoiced. 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. """ if sag_id is not None: try: sid = int(sag_id) except (TypeError, ValueError): sid = None if sid and sid > 0: 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: 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, s.status AS case_status, 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) if settings.WORKFLOWS_ENABLED: _attach_workflow_decisions(rows) 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")) workflow_decision = row.get('workflow_decision') 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", "case_status": row.get("case_status"), "customer_name": row.get("customer_name") or "Ukendt kunde", "hours": hours, "actual_hours": float(row.get("faktisk_tid_min") or round(float(row.get("original_hours") or 0) * 60)) / 60.0, "method": method, "method_label": _settlement_label(method), "valid": True, "message": None, } if row.get('workflow_active') and (not workflow_decision or workflow_decision.get('status') not in {'decided','overridden'}): item.update(valid=False, message="Afventer workflowbeslutning eller manuel vurdering") elif row.get('workflow_stale'): item.update(valid=False, message="Workflowbeslutningen skal genberegnes") elif 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 {} # Invoice quantities must match the draft that will be created below. # Per-entry rounding only applies when debiting a prepaid card. hours = sum( float(row.get("approved_hours") or row.get("original_hours") or 0) 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.""" if settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE == 'active': from app.workflows.integration import active_for_customer from app.workflows.settlement import workflow_customer, settle_workflow_entries scoped = [active_for_customer(workflow_customer(tid)) for tid in payload.ids] if any(scoped): if not all(scoped): raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig') if payload.billing_method or payload.prepaid_card_id or payload.fixed_price_agreement_id: raise HTTPException(409, 'Brug begrundet overstyring af workflowbeslutningen før afregning') from app.core.auth_dependencies import get_current_user, require_permission actor = await get_current_user(request, None) await require_permission('workflows.settle')(actor) return settle_workflow_entries(payload.ids, actor['id'], payload.line_mode) 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) try: placeholders = ",".join(["%s"] * len(ids)) rows = execute_query( f""" SELECT t.id, t.customer_id, t.case_id, t.sag_id, t.status, t.billable, t.billing_method, t.original_hours, t.approved_hours, t.worked_date, COALESCE(c.title, s.titel, 'Time entries') AS case_title 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 WHERE t.id IN ({placeholders}) AND t.vtiger_id IS NULL AND t.billed_via_thehub_id IS NULL AND t.status <> 'billed' """, tuple(ids), ) if not rows: raise HTTPException(status_code=400, detail="No eligible entries found") # Local order creation must not depend on e-conomic data/mapping. # Selected entries are converted to local orders regardless of billing method. selected_order_ids = [int(r["id"]) for r in rows] if not selected_order_ids: raise HTTPException(status_code=400, detail="No selected entries found") placeholders_invoice = ",".join(["%s"] * len(selected_order_ids)) execute_update( f""" UPDATE tmodule_times SET status = 'approved', entry_status = 'godkendt', approved_hours = COALESCE(approved_hours, original_hours), approved_at = COALESCE(approved_at, CURRENT_TIMESTAMP), updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders_invoice}) AND status <> 'billed' """, tuple(selected_order_ids), ) rows_by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list) skipped_missing_customer: List[int] = [] for row in rows: if int(row["id"]) not in selected_order_ids: continue resolved_customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id")) if not resolved_customer_id: skipped_missing_customer.append(int(row["id"])) continue rows_by_customer[int(resolved_customer_id)].append(row) created_drafts = [] failed_customers: List[Dict[str, Any]] = [] for cust_id, cust_rows in rows_by_customer.items(): try: draft_id = _create_ordre_draft_from_selected(cust_id, cust_rows, user_id) created_drafts.append({"customer_id": cust_id, "draft_id": draft_id}) except HTTPException as ex: failed_customers.append( { "customer_id": cust_id, "entry_ids": [int(r.get("id")) for r in cust_rows if r.get("id") is not None], "error": str(ex.detail), } ) if not created_drafts: if skipped_missing_customer: raise HTTPException( status_code=400, detail="No local orders created: selected entries are missing customer linkage", ) if failed_customers: raise HTTPException( status_code=400, detail="No local orders created: customer data is invalid for selected entries", ) raise HTTPException(status_code=400, detail="No local orders created") # Time queue must never push directly to e-conomic. # Orders are created locally and can be transferred manually from Orders page. draft_ids = [o["draft_id"] for o in created_drafts] orders_url = "/ordre" if len(draft_ids) == 1: orders_url = f"/ordre/{draft_ids[0]}" return { "success": True, "selected": len(ids), "order_candidates": len(selected_order_ids), "created_drafts": created_drafts, "created_orders": [{"customer_id": d["customer_id"], "order_id": d["draft_id"]} for d in created_drafts], "skipped_missing_customer": skipped_missing_customer, "failed_customers": failed_customers, "orders_url": orders_url, "message": "Ordrekladder oprettet i /ordre. Klar til konsolidering og overfoersel.", } except HTTPException: raise except Exception as e: logger.error("Failed send-to-invoices flow: %s", e) raise HTTPException(status_code=500, detail="Failed sending selected entries to invoices") def _protect_workflow_selection(ids): if not (settings.WORKFLOWS_ENABLED and settings.WORKFLOWS_MODE=='active'): return from app.workflows.integration import active_for_customer from app.workflows.settlement import workflow_customer if any(active_for_customer(workflow_customer(tid)) for tid in ids): raise HTTPException(409, 'Brug workflowbeslutningens begrundede overstyring og afregning') def _attach_case_tags(rows): """Load tags once for all visible cases, including legacy case-only tags.""" case_ids = sorted({int(row['sag_id']) for row in rows if row.get('sag_id')}) by_case = {} if case_ids: tags = execute_query('''SELECT et.entity_id AS sag_id,t.id,t.name,t.color,t.icon FROM entity_tags et JOIN tags t ON t.id=et.tag_id WHERE et.entity_type='case' AND et.entity_id=ANY(%s) ORDER BY t.type,t.name,t.id''', (case_ids,)) or [] for tag in tags: by_case.setdefault(int(tag['sag_id']), []).append({key: tag.get(key) for key in ('id','name','color','icon')}) missing = [case_id for case_id in case_ids if case_id not in by_case] if missing: legacy = execute_query('SELECT sag_id,id,tag_navn AS name FROM sag_tags WHERE sag_id=ANY(%s) AND deleted_at IS NULL ORDER BY tag_navn,id', (missing,)) or [] for tag in legacy: by_case.setdefault(int(tag['sag_id']), []).append({'id':tag['id'],'name':tag['name'],'color':None,'icon':None}) for row in rows: row['case_tags'] = by_case.get(int(row['sag_id']), []) if row.get('sag_id') else [] def _attach_workflow_decisions(rows): if not rows: return from app.workflows.integration import active_for_customer ids=[int(row['id']) for row in rows] decisions=execute_query('''SELECT d.*,t.workflow_revision AS entry_revision FROM bmc_billing_decisions d JOIN tmodule_times t ON t.id=d.time_id WHERE d.time_id=ANY(%s) AND d.is_current''',(ids,)) or [] by_id={int(d['time_id']):d for d in decisions} customer_activation = {} for row in rows: decision=by_id.get(int(row['id'])) row['workflow_decision']=decision customer_id = row.get('billing_customer_id') or row.get('customer_id') if customer_id not in customer_activation: customer_activation[customer_id] = active_for_customer(customer_id) row['workflow_active']=customer_activation[customer_id] row['workflow_stale']=bool(decision and decision['revision']!=decision['entry_revision']) if row['workflow_active'] and decision and decision.get('method') and not (row.get('economy_settled_at') or row.get('economy_order_draft_id') or row.get('billed_via_thehub_id')): row['billing_method']=decision['method'] row['billable']=decision['method'] in ('invoice','prepaid') config=(decision.get('details') or {}).get('billing') or (decision.get('details') or {}).get('decision') or {} row['prepaid_card_id']=config.get('card_id') row['fixed_price_agreement_id']=config.get('agreement_id') class ReviewedTimeSnapshot(BaseModel): id: int = Field(..., gt=0) updated_at: str entry_fingerprint: str billing_method: str prepaid_card_id: Optional[int] = None fixed_price_agreement_id: Optional[int] = None workflow_revision: Optional[int] = None workflow_decision_id: Optional[int] = None class ReviewedTimeRequest(BaseModel): model_config = {'extra': 'forbid'} ids: List[int] = Field(..., min_length=1) snapshots: List[ReviewedTimeSnapshot] = Field(..., min_length=1) line_mode: str = Field(default='separate', pattern='^(separate|combined)$') def _queue_lifecycle(row): method = _normalise_billing_method(row.get('billing_method')) sent = bool(row.get('economy_order_draft_id') or row.get('billed_via_thehub_id')) invoice = bool(row.get('economic_invoice_number')) or row.get('order_sync_status') in {'posted', 'paid'} settled = bool(row.get('economy_settled_at') or row.get('status') == 'billed') if method != 'invoice' and settled: stage, label = 'settled', 'Afregnet' elif invoice: stage, label = 'invoiced', 'Faktureret' elif sent: stage, label = 'sent', 'Sendt til fakturering' elif row.get('status') == 'approved': stage, label = 'approved', 'Godkendt' elif row.get('status') == 'billed': # Legacy "billed" alone does not establish a verified invoice. stage, label = 'settled', 'Afregnet' else: stage, label = 'pending', 'Afventer godkendelse' return dict(lifecycle_status=stage, lifecycle_label=label, can_approve=stage == 'pending' and row.get('status') != 'rejected', can_change_billing=stage == 'pending' and row.get('status') != 'rejected', can_settle=stage in {'pending', 'approved'} and row.get('status') != 'rejected', order_url=f"/ordre/{int(row['economy_order_draft_id'])}" if row.get('economy_order_draft_id') else None) def _validate_reviewed_rows(payload, raw): ids = _ensure_ids(payload.ids) snapshots = {item.id: item for item in payload.snapshots} if len(snapshots) != len(payload.snapshots) or set(snapshots) != set(ids): raise HTTPException(422, 'Der skal være præcis én gennemgået registrering for hver valgt tid') if {int(row['id']) for row in raw} != set(ids): raise HTTPException(404, 'En tidsregistrering findes ikke længere') if any(row.get('vtiger_id') or row.get('billed_via_thehub_id') or row.get('economy_order_draft_id') or row.get('economy_settled_at') or row.get('status') in {'billed', 'rejected'} for row in raw): raise HTTPException(409, 'En valgt tid er allerede behandlet eller afvist. Genindlæs køen.') selected = _selected_time_entries(ids) if settings.WORKFLOWS_ENABLED: _attach_workflow_decisions(selected) by_id = {int(row['id']): row for row in selected} for row in raw: snapshot = snapshots[int(row['id'])] current = by_id.get(int(row['id'])) if not current: raise HTTPException(409, 'Tiden findes ikke længere i køen') if snapshot.entry_fingerprint != _queue_review_fingerprint(current): raise HTTPException(409, 'Tid, kunde eller beskrivelse er ændret. Genindlæs køen.') # Compare the database timestamp, not a rounded display value. timestamp = row.get('updated_at') from datetime import datetime try: reviewed_stamp = datetime.fromisoformat(snapshot.updated_at.replace('Z', '+00:00')) if isinstance(timestamp, str): timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) except (ValueError, TypeError): raise HTTPException(409, 'Genindlæs tiden før godkendelse') if timestamp != reviewed_stamp: raise HTTPException(409, 'Tiden er ændret siden gennemgangen. Genindlæs køen.') for key in ('billing_method', 'prepaid_card_id', 'fixed_price_agreement_id'): if getattr(snapshot, key) != current.get(key): raise HTTPException(409, 'Afregningsvalget er ændret. Genindlæs køen.') if current.get('workflow_active') and snapshot.workflow_revision != int(row.get('workflow_revision') or 0): raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.') if current.get('workflow_active') and snapshot.workflow_decision_id != (current.get('workflow_decision') or {}).get('id'): raise HTTPException(409, 'Workflowbeslutningen er ændret. Genindlæs køen.') if current.get('workflow_active') and current.get('workflow_stale'): raise HTTPException(409, 'Workflowbeslutningen er forældet') for current in selected: if current.get('workflow_active'): from app.workflows.settlement import current_decision from app.workflows.service import load_time_context from app.workflows.classification import enrich_time_context from app.core.database import _transaction_connection from psycopg2.extras import RealDictCursor current_decision(current['id'], by_id[current['id']].get('workflow_decision', {}).get('entry_revision')) with _transaction_connection.get().cursor(cursor_factory=RealDictCursor) as cursor: context = enrich_time_context(cursor, load_time_context(current['id'], cursor)) if context.get('pending_billing_tag_suggestions'): raise HTTPException(409, 'Afvent godkendelse eller afvisning af relevante tags') preview = _build_settlement_preview(SettlementRequest(ids=ids, line_mode=payload.line_mode)) if not preview['valid']: raise HTTPException(409, {'message': 'Ret fejlene før godkendelse', 'preview': preview}) return selected @router.post('/time-queue/approve-reviewed') async def approve_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request): from app.core.auth_dependencies import get_current_user, require_permission from app.core.database import atomic_transaction actor = await _queue_actor(request) await require_permission('billing.approve')(actor) ids = _ensure_ids(payload.ids) with atomic_transaction(): raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or [] rows = _validate_reviewed_rows(payload, raw) if any(row.get('workflow_active') for row in rows): await require_permission('workflows.settle')(actor) changed = [int(row['id']) for row in raw if row.get('status') != 'approved'] if changed: execute_update("""UPDATE tmodule_times SET status='approved',entry_status='godkendt', approved_hours=COALESCE(approved_hours,original_hours), approved_at=now(),approved_by=%s, updated_at=now(),aktiv_timer=false WHERE id=ANY(%s)""", (actor['id'], changed)) return {'success': True, 'approved': len(changed), 'already_approved': len(ids)-len(changed), 'message': 'Tiderne er godkendt. Der er ikke trukket timer eller oprettet ordrekladder.'} @router.post('/time-queue/send-reviewed') async def send_reviewed_time_queue(payload: ReviewedTimeRequest, request: Request): """Revalidate the reviewed selection under locks before any local financial effects.""" from app.core.auth_dependencies import get_current_user, require_permission from app.core.database import atomic_transaction from app.workflows.integration import active_for_customer from app.workflows.settlement import workflow_customer, settle_workflow_entries actor = await _queue_actor(request) await require_permission('billing.approve')(actor) ids = _ensure_ids(payload.ids) scoped = [active_for_customer(workflow_customer(tid)) for tid in ids] if settings.WORKFLOWS_ENABLED else [False]*len(ids) if any(scoped): if not all(scoped): raise HTTPException(409, 'Vælg workflowkunder og øvrige kunder hver for sig') await require_permission('workflows.settle')(actor) return settle_workflow_entries(ids, actor['id'], payload.line_mode, review_validator=lambda raw: _validate_reviewed_rows(payload, raw)) with atomic_transaction(): raw = execute_query('SELECT * FROM tmodule_times WHERE id=ANY(%s) ORDER BY id FOR UPDATE', (ids,)) or [] rows = _validate_reviewed_rows(payload, raw) if any(row.get('workflow_active') for row in rows): raise HTTPException(409, 'Workflowaktivering er ændret. Genindlæs køen.') return await settle_time_queue(SettlementRequest(ids=ids, line_mode=payload.line_mode), request) async def _queue_actor(request): from app.core.auth_dependencies import get_current_user from fastapi.security import HTTPAuthorizationCredentials header = request.headers.get('authorization', '') credentials = None if header.lower().startswith('bearer '): credentials = HTTPAuthorizationCredentials(scheme='Bearer', credentials=header[7:].strip()) return await get_current_user(request, credentials) def _queue_review_fingerprint(row): import hashlib fields = ('billing_method','prepaid_card_id','fixed_price_agreement_id','original_hours', 'approved_hours','faktisk_tid_min','fakturerbar_tid_min','round_block_min', 'worked_date','description','billing_customer_id','sag_id','case_id','billable') values = {} for field in fields: value = row.get(field) if field in {'original_hours','approved_hours'} and value is not None: value = str(Decimal(str(value)).normalize()) elif field == 'worked_date' and value is not None: value = str(value) values[field] = value encoded = json.dumps(values, sort_keys=True, default=str, ensure_ascii=False, separators=(',', ':')) return hashlib.sha256(encoded.encode('utf-8')).hexdigest()