release: v2.6.0
This commit is contained in:
parent
6906568dc2
commit
d0f27b78d5
24
.env.example
24
.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)
|
||||
# =====================================================
|
||||
|
||||
@ -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)
|
||||
# =====================================================
|
||||
|
||||
34
MDfile/RELEASE_NOTES_v2.6.0.md
Normal file
34
MDfile/RELEASE_NOTES_v2.6.0.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Release Notes: v2.6.0
|
||||
|
||||
**Dato:** 25. august 2026
|
||||
|
||||
## Overblik
|
||||
|
||||
Version 2.6.0 udvider BMC Hub med Shipmondo-forsendelser, en samlet afregningsgang for tidskøen og en mere robust produktintegration. Sagsvisningen har samtidig fået forbedret historik og arbejdsgange omkring produkter og forsendelser.
|
||||
|
||||
## Shipmondo
|
||||
|
||||
- Ny Shipmondo-integration med produktoversigt, lokale bookingkladder, afsendelse, tracking og PDF-labels.
|
||||
- Forsendelser knyttes til sag, kunde og kontakt og gemmes med pakker, status og API-resultat.
|
||||
- Sikker standardopsætning med integrationen deaktiveret samt `read-only` og `dry-run` aktiveret.
|
||||
- Nye miljøindstillinger og Docker-konfiguration til API-adgang og afsenderoplysninger.
|
||||
|
||||
## Tidskø og afregning
|
||||
|
||||
- Valgte tidsregistreringer kan forhåndsvalideres og afregnes samlet.
|
||||
- Understøttelse af ordrekladde, klippekort og ikke-fakturerbar afregning uden at blande betalingsmetoder.
|
||||
- Oprettede ordrekladder og afregningsoplysninger spores direkte på tidsregistreringerne, så dobbeltbehandling undgås.
|
||||
- Kundeidentifikation prioriterer sagens virksomhed og håndterer ældre trackingdata som fallback.
|
||||
|
||||
## Produkter og sager
|
||||
|
||||
- Produktdata fra API Gateway normaliseres på tværs af leverandørernes forskellige feltnavne og payload-formater.
|
||||
- Produktsøgning, sortering og import er gjort mere robust ved manglende eller indlejrede data.
|
||||
- Købs- og salgslinjer kan oprettes atomisk på en sag fra én produkthandling.
|
||||
- Sagsdetaljen og historikken viser flere relevante handlinger og tidsstempler.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Målrettede tests for sager, tidskø, produkter og Shipmondo: **45 bestået**.
|
||||
- Python-kompilering og diff-kontrol er gennemført.
|
||||
- Den samlede vedligeholdte testsuite har 131 beståede og 7 eksisterende fejl i databaseafhængige eller andre ikke-berørte moduler.
|
||||
@ -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
|
||||
|
||||
@ -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,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||
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.
|
||||
|
||||
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:
|
||||
`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
|
||||
standard_hourly_rate,
|
||||
standard_margin_percent,
|
||||
special_freight_price,
|
||||
supplier_service_enrolled,
|
||||
invoice_fee_amount
|
||||
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 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))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -1,510 +1,65 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Economy Time Queue{% endblock %}
|
||||
|
||||
{% block title %}Tidskø til afregning{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Economy Time Queue</h2>
|
||||
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
|
||||
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
|
||||
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
|
||||
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
|
||||
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
|
||||
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
|
||||
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
|
||||
</div>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterCustomer" class="form-label">Firma</label>
|
||||
<select id="filterCustomer" class="form-select">
|
||||
<option value="">Alle firmaer med ubehandlede registreringer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterStatus" class="form-label">Status</label>
|
||||
<select id="filterStatus" class="form-select">
|
||||
<option value="">All</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="approved">approved</option>
|
||||
<option value="rejected">rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterBillable" class="form-label">Billable</label>
|
||||
<select id="filterBillable" class="form-select">
|
||||
<option value="">All</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterQuery" class="form-label">Search</label>
|
||||
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bulkDescription" class="form-label">Description</label>
|
||||
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="bulkHours" class="form-label">Hours</label>
|
||||
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="bulkBillingMethod" class="form-label">Billing method</label>
|
||||
<select id="bulkBillingMethod" class="form-select">
|
||||
<option value="">No change</option>
|
||||
<option value="invoice">invoice</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="prepaid">prepaid</option>
|
||||
<option value="fixed_price">fixed_price</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
|
||||
<select id="bulkPrepaidCard" class="form-select">
|
||||
<option value="">Select card</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
|
||||
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
|
||||
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
|
||||
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
|
||||
<th>ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th>Case</th>
|
||||
<th>Hours</th>
|
||||
<th>Status</th>
|
||||
<th>Billable</th>
|
||||
<th>Method</th>
|
||||
<th>Hours edit</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queueBody">
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-queue { max-width: 1700px; }
|
||||
.queue-summary { background:linear-gradient(135deg,#f7fbff,#fff); border:1px solid #d8e3ed; border-radius:1rem; }
|
||||
.queue-stat { min-width:150px; }.queue-stat strong{font-size:1.5rem;color:#15476c}
|
||||
.time-group { border:1px solid #dce5ed;border-radius:.85rem;overflow:hidden }.time-group header{background:#f5f8fb;border-bottom:1px solid #dce5ed}
|
||||
.time-group header { padding:.45rem .75rem!important; font-size:.85rem; }
|
||||
.time-row{border-left:4px solid #0d6efd;padding:.42rem .7rem!important;min-height:43px}.time-row[data-method="prepaid"]{border-left-color:#6f42c1}.time-row[data-method="subscription"]{border-left-color:#198754}.time-row[data-method="internal"],.time-row[data-method="non_billable"]{border-left-color:#6c757d}.time-row+.time-row{border-top:1px solid #edf1f4}
|
||||
.time-row .row { display:grid;grid-template-columns:22px minmax(290px,2.4fr) minmax(125px,.85fr) minmax(94px,.55fr) minmax(170px,.9fr) 82px;gap:.5rem;align-items:center;margin:0; }
|
||||
.time-row .row>[class*="col"] { width:auto;max-width:none;flex:none;padding:0;min-width:0; }
|
||||
.time-row .col-12.col-lg-4>div { display:inline; }.time-row .col-12.col-lg-4>div+div:before { content:' · ';color:#94a3b8; }
|
||||
.time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; }
|
||||
.time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; }
|
||||
.time-row .col-12.col-lg-4,.time-row .col-6,.time-row .col-8 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; }
|
||||
.time-row > .mt-2 { display:none; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; }
|
||||
@media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}}
|
||||
@media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}}
|
||||
.queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem}
|
||||
.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#6c757d;margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}}
|
||||
</style>
|
||||
<main class="container-fluid py-4 time-queue">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
|
||||
<div id="messageArea" aria-live="polite"></div>
|
||||
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Intet afregnes uden forhåndsvisning.</div></div></section>
|
||||
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Status</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-4"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
|
||||
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
|
||||
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Kontrollér beløb og fejl før afregningen gennemføres.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></section>
|
||||
<div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div>
|
||||
<section id="queueContent" aria-live="polite"></section>
|
||||
<div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const state = {
|
||||
items: [],
|
||||
selected: new Set(),
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const queueBody = document.getElementById('queueBody');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const selectedCountBadge = document.getElementById('selectedCountBadge');
|
||||
|
||||
const filterCustomer = document.getElementById('filterCustomer');
|
||||
const filterStatus = document.getElementById('filterStatus');
|
||||
const filterBillable = document.getElementById('filterBillable');
|
||||
const filterQuery = document.getElementById('filterQuery');
|
||||
|
||||
const bulkDescription = document.getElementById('bulkDescription');
|
||||
const bulkHours = document.getElementById('bulkHours');
|
||||
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
|
||||
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
|
||||
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
|
||||
|
||||
function selectedIds() {
|
||||
return Array.from(state.selected);
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
if (!state.items.length) {
|
||||
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
queueBody.innerHTML = state.items.map((item) => {
|
||||
const id = Number(item.id);
|
||||
const checked = state.selected.has(id) ? 'checked' : '';
|
||||
const date = item.worked_date || '-';
|
||||
const hours = item.approved_hours || item.original_hours || 0;
|
||||
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
|
||||
const title = item.case_title || '-';
|
||||
const desc = (item.description || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const method = item.billing_method || 'invoice';
|
||||
return `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
|
||||
<td>${id}</td>
|
||||
<td>${customer}</td>
|
||||
<td>${date}</td>
|
||||
<td>${title}</td>
|
||||
<td>${hours}</td>
|
||||
<td>${item.status || '-'}</td>
|
||||
<td>${item.billable === false ? 'false' : 'true'}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm inline-method" data-id="${id}">
|
||||
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
|
||||
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
|
||||
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
|
||||
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.querySelectorAll('.row-check').forEach((cb) => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const id = Number(e.target.dataset.id);
|
||||
if (e.target.checked) state.selected.add(id);
|
||||
else state.selected.delete(id);
|
||||
syncSelectAll();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.inline-save').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const id = Number(e.target.dataset.id);
|
||||
await saveInlineRow(id, e.target);
|
||||
});
|
||||
});
|
||||
|
||||
syncSelectAll();
|
||||
}
|
||||
|
||||
function syncSelectAll() {
|
||||
const ids = state.items.map((x) => Number(x.id));
|
||||
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
|
||||
selectAll.checked = Boolean(allSelected);
|
||||
selectedCountBadge.textContent = `${state.selected.size} selected`;
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function buildListUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
|
||||
if (filterStatus.value) params.set('status', filterStatus.value);
|
||||
if (filterBillable.value) params.set('billable', filterBillable.value);
|
||||
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
|
||||
params.set('limit', '500');
|
||||
return `/api/v1/economy/time-queue?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
if (state.loading) return;
|
||||
state.loading = true;
|
||||
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
|
||||
try {
|
||||
const data = await api(buildListUrl());
|
||||
state.items = data.items || [];
|
||||
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
|
||||
renderRows();
|
||||
} catch (err) {
|
||||
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrepaidCards() {
|
||||
try {
|
||||
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
|
||||
const opts = ['<option value="">Select card</option>'];
|
||||
(data.items || []).forEach((card) => {
|
||||
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
|
||||
opts.push(`<option value="${card.id}">${label}</option>`);
|
||||
});
|
||||
bulkPrepaidCard.innerHTML = opts.join('');
|
||||
} catch (_) {
|
||||
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const data = await api('/api/v1/economy/time-queue/customers');
|
||||
const current = filterCustomer.value;
|
||||
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
|
||||
(data.items || []).forEach((row) => {
|
||||
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
|
||||
opts.push(`<option value="${row.customer_id}">${label}</option>`);
|
||||
});
|
||||
filterCustomer.innerHTML = opts.join('');
|
||||
if (current) {
|
||||
filterCustomer.value = current;
|
||||
}
|
||||
} catch (_) {
|
||||
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
filterCustomer.value = '';
|
||||
filterStatus.value = '';
|
||||
filterBillable.value = '';
|
||||
filterQuery.value = '';
|
||||
setActiveQuickFilter(null);
|
||||
await loadEntries();
|
||||
}
|
||||
|
||||
function setActiveQuickFilter(active) {
|
||||
quickFilterBtns.forEach((btn) => {
|
||||
const isActive = btn.dataset.filter === active;
|
||||
btn.classList.toggle('btn-primary', isActive);
|
||||
btn.classList.toggle('btn-outline-primary', !isActive);
|
||||
});
|
||||
}
|
||||
|
||||
async function applyQuickFilter(type) {
|
||||
if (type === 'pending') {
|
||||
filterStatus.value = 'pending';
|
||||
filterBillable.value = '';
|
||||
} else if (type === 'billable') {
|
||||
filterStatus.value = '';
|
||||
filterBillable.value = 'true';
|
||||
} else if (type === 'ready') {
|
||||
filterStatus.value = 'approved';
|
||||
filterBillable.value = 'true';
|
||||
}
|
||||
setActiveQuickFilter(type);
|
||||
await loadEntries();
|
||||
}
|
||||
|
||||
async function saveInlineRow(id, buttonEl) {
|
||||
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
|
||||
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
|
||||
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
|
||||
if (!hoursInput || !descInput || !methodSelect) return;
|
||||
|
||||
const originalHours = Number(hoursInput.value);
|
||||
const description = (descInput.value || '').trim();
|
||||
const billingMethod = methodSelect.value;
|
||||
|
||||
if (!originalHours || originalHours <= 0) {
|
||||
alert('Hours must be greater than 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const prevText = buttonEl.textContent;
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = 'Gemmer...';
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-update', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [id],
|
||||
original_hours: originalHours,
|
||||
description,
|
||||
billing_method: billingMethod,
|
||||
}),
|
||||
});
|
||||
await loadEntries();
|
||||
await loadCustomers();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = prevText;
|
||||
}
|
||||
}
|
||||
|
||||
async function doBulkUpdate() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const payload = { ids };
|
||||
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
|
||||
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
|
||||
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
|
||||
|
||||
if (!payload.description && !payload.original_hours && !payload.billing_method) {
|
||||
return alert('Set at least one update field');
|
||||
}
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-update', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doBulkApprove() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const payload = { ids };
|
||||
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-approve', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doBulkDelete() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
|
||||
if (reason === null) return;
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, reason }),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doApplyPrepaid() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doSendInvoices() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overførsel til e-conomic)');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
|
||||
const draftId = x.draft_id || x.order_id;
|
||||
return `customer ${x.customer_id}, draft ${draftId}`;
|
||||
}).join('\n');
|
||||
const skipped = (result.skipped_missing_customer || []);
|
||||
const failedCustomers = (result.failed_customers || []);
|
||||
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
|
||||
const nextStep = result.orders_url ? `\n\nÅbn ordre: ${result.orders_url}` : '';
|
||||
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
||||
const failedMsg = failedCustomers.length
|
||||
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
|
||||
: '';
|
||||
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
if (selectAll.checked) {
|
||||
state.items.forEach((item) => state.selected.add(Number(item.id)));
|
||||
} else {
|
||||
state.items.forEach((item) => state.selected.delete(Number(item.id)));
|
||||
}
|
||||
renderRows();
|
||||
});
|
||||
|
||||
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
|
||||
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
|
||||
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
|
||||
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
|
||||
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
|
||||
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
|
||||
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
|
||||
quickFilterBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
|
||||
});
|
||||
|
||||
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
|
||||
el.addEventListener('change', loadEntries);
|
||||
});
|
||||
filterQuery.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') loadEntries();
|
||||
});
|
||||
|
||||
loadCustomers();
|
||||
loadPrepaidCards();
|
||||
loadEntries();
|
||||
const state={items:[],selected:new Set(),preview:null,loading:false,cards:[]}; const $=id=>document.getElementById(id);
|
||||
const labels={pending:'Afventer',approved:'Godkendt',rejected:'Afvist',invoice:'Faktura',prepaid:'Klippekort',subscription:'Abonnement / fast pris',internal:'Intern tid',non_billable:'Ikke-fakturerbar tid'};
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2});
|
||||
const norm=v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[String(v||'invoice').toLowerCase()]||String(v||'invoice').toLowerCase(); const ids=()=>[...state.selected];
|
||||
function message(type,text){$('messageArea').innerHTML=`<div class="alert alert-${type} alert-dismissible fade show">${esc(text)}<button class="btn-close" data-bs-dismiss="alert"></button></div>`}
|
||||
async function api(url,options={}){const r=await fetch(url,{headers:{'Content-Type':'application/json'},...options});const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='object'?(d.detail.message||'Handlingen kunne ikke gennemføres'):(d.detail||'Handlingen kunne ikke gennemføres'));e.payload=d;throw e}return d}
|
||||
function payload(){const method=$('settlementMethod').value,card=Number($('prepaidCard').value),agreement=Number($('agreementId').value);return {ids:ids(),...(method?{billing_method:method}:{}),...(card?{prepaid_card_id:card}:{}),...(agreement?{fixed_price_agreement_id:agreement}:{})}}
|
||||
function status(v){return `<span class="badge ${v==='approved'?'text-bg-success':v==='rejected'?'text-bg-danger':'text-bg-warning'}">${esc(labels[v]||v||'Ukendt')}</span>`} function method(v){const m=norm(v),cls=m==='invoice'?'text-bg-primary':m==='prepaid'?'text-bg-purple':m==='subscription'?'text-bg-success':'text-bg-secondary';return `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`}
|
||||
function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)}
|
||||
function selectedRows(){return state.items.filter(x=>state.selected.has(Number(x.id)))}
|
||||
function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]}
|
||||
function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)}
|
||||
function actualHours(rows){return rows.reduce((total,x)=>total+Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)/60,0)}
|
||||
function cardHours(rows,block){const safeBlock=Math.max(1,Number(block)||30);return rows.reduce((total,x)=>{const minutes=Math.max(0,Math.round(Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)));return total+(minutes?Math.ceil(minutes/safeBlock)*safeBlock/60:0)},0)}
|
||||
function renderPrepaidHelp(){const selected=selectedRows(),customers=selectedCardCustomerIds(),card=state.cards.find(x=>Number(x.id)===Number($('prepaidCard').value)),storedHours=selectedHours();if(!selected.length){$('prepaidHelp').textContent='Vælg tidsregistreringer for at se kundens klippekort.';return}if(customers.length!==1){$('prepaidHelp').innerHTML='<span class="text-danger">Vælg kun tider fra ét firma for at bruge ét klippekort.</span>';return}const company=selected[0].customer_name||'den valgte kunde';if(!card){$('prepaidHelp').textContent=`${esc(company)} · Fakturerbar tid: ${h(storedHours)} timer. Vælg et klippekort for at se kortets afrunding.`;return}const block=Number(card.rounding_minutes)||30,actual=actualHours(selected),hours=cardHours(selected,block),remaining=Number(card.remaining_hours||0),after=remaining-hours;$('prepaidHelp').innerHTML=`${esc(company)} · Faktisk tid: <strong>${h(actual)} t</strong> → klippes som <strong>${h(hours)} t</strong> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
|
||||
function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-link customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg kunde</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{e.dataset.ids.split(',').forEach(id=>state.selected.add(Number(id)));selectionChanged()}))}
|
||||
function url(){const p=new URLSearchParams({limit:'500'});if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`}
|
||||
async function loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${Number(x.customer_id)}">${esc(x.customer_name)} (${Number(x.open_count||0)})</option>`).join('')}`;$('filterCustomer').value=v}catch(_){message('warning','Firmafilteret kunne ikke hentes.') }}
|
||||
async function loadCards(){const select=$('prepaidCard'),customers=selectedCardCustomerIds(),current=Number(select.value);state.cards=[];select.disabled=true;if(customers.length!==1){select.innerHTML=`<option value="">${customers.length>1?'Vælg tider fra ét firma':'Vælg først tider fra ét firma'}</option>`;renderPrepaidHelp();return}try{const d=await api(`/api/v1/economy/time-queue/prepaid-cards?customer_id=${customers[0]}`);state.cards=(d.items||[]).filter(x=>x.status==='active'&&Number(x.remaining_hours)>0);select.innerHTML=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${Number(x.id)}">${esc(x.card_number)} · ${h(x.remaining_hours)} timer tilbage</option>`).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
|
||||
async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';try{const d=await api(url());state.items=d.items||[];state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`}finally{state.loading=false}}
|
||||
async function saveMethod(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');const m=$('settlementMethod').value;if(!m)return message('warning','Vælg en afregningstype, der skal gemmes.');try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify({ids:ids(),billing_method:m,billable:['invoice','prepaid'].includes(m)})});message('success','Afregningstypen er gemt.');await load()}catch(e){message('danger',e.message)}}
|
||||
async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v3`;$('caseDetailBody').innerHTML='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}}
|
||||
function previewView(d){state.preview=d;$('previewPanel').classList.remove('d-none');const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[];$('previewBody').innerHTML=`${errors.length?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider er klar til afregning.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladden.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden fakturalinje.</div></div></div>`).join('')}</div>`;$('settleBtn').disabled=!d.valid;$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'})}
|
||||
async function preview(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');try{previewView(await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(payload())}))}catch(e){message('danger',e.message)}}
|
||||
async function settle(){if(!state.preview?.valid)return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Afregner…';try{const d=await api('/api/v1/economy/time-queue/settle',{method:'POST',body:JSON.stringify(payload())});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();$('previewPanel').classList.add('d-none');if(d.orders_url)$('messageArea').insertAdjacentHTML('beforeend',`<p><a class="btn btn-outline-primary" href="${esc(d.orders_url)}">Åbn ordrekladde</a></p>`);await loadCustomers();await load()}catch(e){message('danger',e.message);if(e.payload?.detail?.preview)previewView(e.payload.detail.preview)}finally{$('settleBtn').disabled=false;$('settleBtn').innerHTML='<i class="bi bi-check2-circle me-1"></i>Gennemfør afregning'}}
|
||||
function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()}
|
||||
$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1536,13 +1536,13 @@ async def delete_todo_step(step_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
||||
|
||||
@router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)])
|
||||
async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
||||
"""Update a case."""
|
||||
try:
|
||||
# Check if case exists
|
||||
check = execute_query(
|
||||
"""
|
||||
SELECT id, status, template_key, priority, ansvarlig_bruger_id, assigned_group_id
|
||||
SELECT *
|
||||
FROM sag_sager
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
@ -1674,6 +1674,18 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||
}
|
||||
|
||||
change_messages: List[str] = []
|
||||
changed_by = "System"
|
||||
try:
|
||||
acting_user_id = _get_user_id_from_request(request)
|
||||
acting_user = execute_query_single(
|
||||
"""SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''),
|
||||
CONCAT('Bruger #', user_id::text)) AS name
|
||||
FROM users WHERE user_id = %s""",
|
||||
(acting_user_id,),
|
||||
)
|
||||
changed_by = str((acting_user or {}).get("name") or f"Bruger #{acting_user_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "status" in updates:
|
||||
old_value = _norm(previous_row.get("status")) or "-"
|
||||
@ -1715,13 +1727,29 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||
if old_group != new_group:
|
||||
change_messages.append(f"👥 Gruppe ændret: {old_group} → {new_group}")
|
||||
|
||||
explicitly_logged = {"status", "template_key", "type", "priority", "ansvarlig_bruger_id", "assigned_group_id"}
|
||||
field_labels = {
|
||||
"titel": "Titel", "beskrivelse": "Beskrivelse", "customer_id": "Kunde",
|
||||
"start_date": "Startdato", "deadline": "Deadline", "deferred_until": "Udskudt til",
|
||||
"deferred_until_case_id": "Afventer sag", "deferred_until_status": "Afventer status",
|
||||
"supplier_flow_type": "Leverandørflow", "supplier_flow_confidence": "Leverandørflow-sikkerhed",
|
||||
}
|
||||
for field in allowed_fields:
|
||||
if field not in updates or field in explicitly_logged:
|
||||
continue
|
||||
old_value = _norm(previous_row.get(field)) or "-"
|
||||
new_value = _norm(updated_row.get(field)) or "-"
|
||||
if old_value != new_value:
|
||||
label = field_labels.get(field, field.replace("_", " ").capitalize())
|
||||
change_messages.append(f"✏️ {label} ændret: {old_value} → {new_value}")
|
||||
|
||||
for message in change_messages:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(sag_id, "System", message, True),
|
||||
(sag_id, changed_by, message, True),
|
||||
)
|
||||
except Exception as comment_log_error:
|
||||
logger.warning("⚠️ Could not log field-change comments for case %s: %s", sag_id, comment_log_error)
|
||||
@ -3146,6 +3174,125 @@ async def create_sale_item(sag_id: int, data: dict):
|
||||
raise HTTPException(status_code=500, detail="Failed to create sale item")
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/sale-items/batch")
|
||||
async def create_sale_items_batch(sag_id: int, data: dict):
|
||||
"""Create purchase and/or sale lines from one selected product atomically.
|
||||
|
||||
A product lookup is one business action, but it can have two accounting
|
||||
consequences: an expected supplier purchase and a customer sale. Keeping
|
||||
both inserts in one transaction prevents half-created product flows.
|
||||
"""
|
||||
lines = data.get("lines") if isinstance(data, dict) else None
|
||||
if not isinstance(lines, list) or not lines:
|
||||
raise HTTPException(status_code=400, detail="lines must contain at least one item")
|
||||
if len(lines) > 2:
|
||||
raise HTTPException(status_code=400, detail="A product action can contain at most one purchase and one sale line")
|
||||
|
||||
try:
|
||||
case_exists = execute_query_single(
|
||||
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,),
|
||||
)
|
||||
if not case_exists:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
if not _table_exists("sag_salgsvarer"):
|
||||
raise HTTPException(status_code=503, detail="Varelinjer er ikke klar endnu")
|
||||
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
normalized_lines = []
|
||||
seen_types = set()
|
||||
for raw_line in lines:
|
||||
if not isinstance(raw_line, dict):
|
||||
raise HTTPException(status_code=400, detail="Every line must be an object")
|
||||
item_type = str(raw_line.get("type") or "").strip().lower()
|
||||
if item_type not in {"sale", "purchase"}:
|
||||
raise HTTPException(status_code=400, detail="type must be sale or purchase")
|
||||
if item_type in seen_types:
|
||||
raise HTTPException(status_code=400, detail="Only one line of each type is allowed")
|
||||
seen_types.add(item_type)
|
||||
|
||||
description = str(raw_line.get("description") or "").strip()
|
||||
if not description:
|
||||
raise HTTPException(status_code=400, detail="description is required")
|
||||
try:
|
||||
amount = float(raw_line.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="amount must be a number")
|
||||
if amount < 0:
|
||||
raise HTTPException(status_code=400, detail="amount cannot be negative")
|
||||
|
||||
status = str(raw_line.get("status") or "draft").strip().lower()
|
||||
if status not in {"draft", "confirmed", "cancelled"}:
|
||||
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
||||
|
||||
purchase_purpose = _normalize_purchase_purpose(raw_line.get("purchase_purpose"), item_type)
|
||||
supplier_invoice_id = supplier_invoice_line_id = None
|
||||
if has_purchase_columns and item_type == "purchase":
|
||||
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
|
||||
raw_line.get("supplier_invoice_id"),
|
||||
raw_line.get("supplier_invoice_line_id"),
|
||||
)
|
||||
|
||||
normalized_lines.append({
|
||||
"type": item_type,
|
||||
"description": description,
|
||||
"quantity": raw_line.get("quantity"),
|
||||
"unit": raw_line.get("unit"),
|
||||
"unit_price": raw_line.get("unit_price"),
|
||||
"amount": amount,
|
||||
"currency": str(raw_line.get("currency") or "DKK").upper(),
|
||||
"status": status,
|
||||
"line_date": raw_line.get("line_date"),
|
||||
"external_ref": raw_line.get("external_ref"),
|
||||
"product_id": raw_line.get("product_id"),
|
||||
"purchase_purpose": purchase_purpose,
|
||||
"supplier_invoice_id": supplier_invoice_id,
|
||||
"supplier_invoice_line_id": supplier_invoice_line_id,
|
||||
})
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
created = []
|
||||
for line in normalized_lines:
|
||||
if has_purchase_columns:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
|
||||
purchase_purpose, supplier_invoice_id, supplier_invoice_line_id)
|
||||
VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s,
|
||||
%(purchase_purpose)s, %(supplier_invoice_id)s, %(supplier_invoice_line_id)s)
|
||||
RETURNING *
|
||||
""",
|
||||
{"sag_id": sag_id, **line},
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
||||
VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s)
|
||||
RETURNING *
|
||||
""",
|
||||
{"sag_id": sag_id, **line},
|
||||
)
|
||||
created.append(dict(cursor.fetchone()))
|
||||
conn.commit()
|
||||
logger.info("✅ Created %s linked product lines for case %s", len(created), sag_id)
|
||||
return {"created": created}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error creating product lines for case %s: %s", sag_id, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to create product lines")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/sale-items/{item_id}")
|
||||
async def get_sale_item(sag_id: int, item_id: int):
|
||||
"""Get a single sale item for a case."""
|
||||
@ -3429,6 +3576,25 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
|
||||
events = []
|
||||
|
||||
case_details_query = f"""
|
||||
SELECT s.id, s.titel, s.created_at,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name
|
||||
FROM sag_sager s
|
||||
LEFT JOIN users u ON u.user_id = s.created_by_user_id
|
||||
WHERE s.id IN ({placeholders})
|
||||
"""
|
||||
for row in execute_query(case_details_query, tuple(case_ids)) or []:
|
||||
created_at = row.get("created_at")
|
||||
if not created_at:
|
||||
continue
|
||||
events.append({
|
||||
"id": f"case:{row.get('id')}:created", "event_type": "case", "event_subtype": "created",
|
||||
"source": "case", "timestamp": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at),
|
||||
"sag_id": row.get("id"), "sag_titel": row.get("titel"),
|
||||
"forfatter": str(row.get("created_by_name") or "System"),
|
||||
"title": "Sag oprettet", "description": str(row.get("titel") or ""),
|
||||
})
|
||||
|
||||
for row in comments:
|
||||
created_at = row.get("created_at")
|
||||
if not created_at:
|
||||
@ -3494,10 +3660,12 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
if _table_exists("sag_reminders"):
|
||||
reminders_query = f"""
|
||||
SELECT r.id, r.sag_id, r.title, r.message, r.event_type,
|
||||
r.next_check_at, r.scheduled_at,
|
||||
r.next_check_at, r.scheduled_at, r.created_at,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name,
|
||||
s.titel AS source_sag_titel
|
||||
FROM sag_reminders r
|
||||
LEFT JOIN sag_sager s ON s.id = r.sag_id
|
||||
LEFT JOIN users u ON u.user_id = r.created_by_user_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND r.sag_id IN ({placeholders})
|
||||
ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC
|
||||
@ -3506,13 +3674,17 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
reminders = execute_query(reminders_query, tuple(case_ids)) or []
|
||||
|
||||
for row in reminders:
|
||||
start_value = row.get("next_check_at") or row.get("scheduled_at")
|
||||
start_value = row.get("created_at") or row.get("next_check_at") or row.get("scheduled_at")
|
||||
if not start_value:
|
||||
continue
|
||||
|
||||
ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value)
|
||||
title = str(row.get("title") or "Påmindelse")
|
||||
message = str(row.get("message") or "")
|
||||
scheduled_value = row.get("scheduled_at") or row.get("next_check_at")
|
||||
if scheduled_value:
|
||||
scheduled_text = scheduled_value.isoformat() if isinstance(scheduled_value, datetime) else str(scheduled_value)
|
||||
message = f"{message}\nPlanlagt til: {scheduled_text}".strip()
|
||||
event_kind = str(row.get("event_type") or "reminder")
|
||||
|
||||
events.append({
|
||||
@ -3523,7 +3695,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
"timestamp": ts,
|
||||
"sag_id": row.get("sag_id"),
|
||||
"sag_titel": row.get("source_sag_titel"),
|
||||
"forfatter": "Reminder",
|
||||
"forfatter": str(row.get("created_by_name") or "System"),
|
||||
"title": f"Påmindelse · {title}",
|
||||
"description": message,
|
||||
})
|
||||
@ -3532,9 +3704,15 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
time_query = f"""
|
||||
SELECT t.id, t.sag_id, t.description, t.entry_type,
|
||||
t.worked_date, t.created_at, t.approved_hours, t.original_hours,
|
||||
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
|
||||
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
|
||||
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
|
||||
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
|
||||
s.titel AS source_sag_titel
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN users employee ON employee.user_id = t.medarbejder_id
|
||||
LEFT JOIN users recorder ON LOWER(recorder.username) = LOWER(t.user_name)
|
||||
WHERE t.sag_id IN ({placeholders})
|
||||
ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC
|
||||
LIMIT 600
|
||||
@ -3542,7 +3720,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
time_entries = execute_query(time_query, tuple(case_ids)) or []
|
||||
|
||||
for row in time_entries:
|
||||
raw_ts = row.get("worked_date") or row.get("created_at")
|
||||
raw_ts = row.get("created_at") or row.get("worked_date")
|
||||
if not raw_ts:
|
||||
continue
|
||||
|
||||
@ -3552,6 +3730,8 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
hours = row.get("original_hours")
|
||||
hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid"
|
||||
description = str(row.get("description") or "")
|
||||
employee_name = str(row.get("employee_name") or "Ukendt medarbejder")
|
||||
description = f"Registreret for: {employee_name}\n{description}".strip()
|
||||
entry_type = str(row.get("entry_type") or "arbejde")
|
||||
|
||||
events.append({
|
||||
@ -3562,11 +3742,70 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
"timestamp": ts,
|
||||
"sag_id": row.get("sag_id"),
|
||||
"sag_titel": row.get("source_sag_titel"),
|
||||
"forfatter": "Tid",
|
||||
"forfatter": str(row.get("actor_name") or "System"),
|
||||
"title": f"Tidsregistrering · {hours_text}",
|
||||
"description": description,
|
||||
})
|
||||
|
||||
if _table_exists("sag_tags"):
|
||||
tag_query = f"""
|
||||
SELECT t.id, t.sag_id, t.tag_navn, t.created_at, t.closed_at, s.titel AS source_sag_titel
|
||||
FROM sag_tags t LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
WHERE t.sag_id IN ({placeholders}) LIMIT 600
|
||||
"""
|
||||
for row in execute_query(tag_query, tuple(case_ids)) or []:
|
||||
for action, timestamp in (("tilføjet", row.get("created_at")), ("lukket", row.get("closed_at"))):
|
||||
if timestamp:
|
||||
events.append({
|
||||
"id": f"tag:{row.get('id')}:{action}", "event_type": "tag", "event_subtype": action,
|
||||
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
|
||||
"sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": "System",
|
||||
"title": f"Tag {action}", "description": str(row.get("tag_navn") or ""),
|
||||
})
|
||||
|
||||
if _table_exists("sag_todo_steps"):
|
||||
todo_query = f"""
|
||||
SELECT t.*, s.titel AS source_sag_titel,
|
||||
COALESCE(NULLIF(TRIM(uc.full_name), ''), NULLIF(TRIM(uc.username), ''), 'System') AS created_by_name,
|
||||
COALESCE(NULLIF(TRIM(ud.full_name), ''), NULLIF(TRIM(ud.username), ''), 'System') AS completed_by_name
|
||||
FROM sag_todo_steps t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN users uc ON uc.user_id = t.created_by_user_id
|
||||
LEFT JOIN users ud ON ud.user_id = t.completed_by_user_id
|
||||
WHERE t.sag_id IN ({placeholders}) LIMIT 800
|
||||
"""
|
||||
for row in execute_query(todo_query, tuple(case_ids)) or []:
|
||||
todo_events = (("oprettet", row.get("created_at"), row.get("created_by_name")),
|
||||
("færdiggjort", row.get("completed_at"), row.get("completed_by_name")),
|
||||
("slettet", row.get("deleted_at"), "System"))
|
||||
for action, timestamp, actor in todo_events:
|
||||
if timestamp:
|
||||
events.append({
|
||||
"id": f"todo:{row.get('id')}:{action}", "event_type": "todo", "event_subtype": action,
|
||||
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
|
||||
"sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": str(actor or "System"),
|
||||
"title": f"Tjeklistepunkt {action}", "description": str(row.get("title") or ""),
|
||||
})
|
||||
|
||||
if _table_exists("sag_relationer"):
|
||||
relation_query = f"""
|
||||
SELECT id, kilde_sag_id, målsag_id, relationstype, created_at, deleted_at
|
||||
FROM sag_relationer
|
||||
WHERE kilde_sag_id IN ({placeholders}) OR målsag_id IN ({placeholders}) LIMIT 600
|
||||
"""
|
||||
for row in execute_query(relation_query, tuple(case_ids) + tuple(case_ids)) or []:
|
||||
current_id = row.get("kilde_sag_id") if row.get("kilde_sag_id") in case_ids else row.get("målsag_id")
|
||||
other_id = row.get("målsag_id") if current_id == row.get("kilde_sag_id") else row.get("kilde_sag_id")
|
||||
for action, timestamp in (("oprettet", row.get("created_at")), ("fjernet", row.get("deleted_at"))):
|
||||
if timestamp:
|
||||
events.append({
|
||||
"id": f"relation:{row.get('id')}:{action}", "event_type": "relation", "event_subtype": action,
|
||||
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
|
||||
"sag_id": current_id, "sag_titel": next((r.get("titel") for r in case_rows if r.get("id") == current_id), None),
|
||||
"forfatter": "System", "title": f"Relation {action}",
|
||||
"description": f"{row.get('relationstype') or 'Relateret til'} · Sag #{other_id}",
|
||||
})
|
||||
|
||||
def _sort_key(item: Dict) -> datetime:
|
||||
raw = str(item.get("timestamp") or "")
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
app/modules/shipmondo/__init__.py
Normal file
1
app/modules/shipmondo/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shipmondo shipping integration."""
|
||||
1
app/modules/shipmondo/backend/__init__.py
Normal file
1
app/modules/shipmondo/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shipmondo backend package."""
|
||||
75
app/modules/shipmondo/backend/api_client.py
Normal file
75
app/modules/shipmondo/backend/api_client.py
Normal file
@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShipmondoApiClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.SHIPMONDO_API_BASE_URL or "").rstrip("/")
|
||||
self.timeout_seconds = max(5, int(settings.SHIPMONDO_TIMEOUT_SECONDS or 30))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and settings.SHIPMONDO_API_USER and settings.SHIPMONDO_API_KEY)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
if not self.configured:
|
||||
raise RuntimeError("Shipmondo API credentials are not configured")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||
auth = aiohttp.BasicAuth(settings.SHIPMONDO_API_USER, settings.SHIPMONDO_API_KEY)
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout, auth=auth, headers=headers) as session:
|
||||
async with session.request(method, url, params=params, json=json_payload) as response:
|
||||
body = await response.text()
|
||||
if response.status >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
detail = str(parsed.get("message") or parsed.get("error") or parsed.get("errors") or "")
|
||||
except (ValueError, AttributeError):
|
||||
detail = body.strip()
|
||||
detail = detail[:300]
|
||||
logger.warning("Shipmondo request failed (%s %s): HTTP %s", method, path, response.status)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise RuntimeError(f"Shipmondo API returned HTTP {response.status}{suffix}")
|
||||
if not body.strip():
|
||||
return {}
|
||||
return json.loads(body)
|
||||
|
||||
async def list_products(self, country_code: str) -> List[Dict[str, Any]]:
|
||||
payload = await self._request("GET", "/products", params={"country_code": country_code.upper()})
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
raw_items = payload.get("items") or payload.get("products") or payload.get("data") or []
|
||||
if isinstance(raw_items, list):
|
||||
return [item for item in raw_items if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
response = await self._request("POST", "/shipments", json_payload=payload)
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("Shipmondo returned an invalid shipment response")
|
||||
return response
|
||||
|
||||
async def get_shipment(self, shipment_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", f"/shipments/{shipment_id}")
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("Shipmondo returned an invalid shipment response")
|
||||
return response
|
||||
66
app/modules/shipmondo/backend/router.py
Normal file
66
app/modules/shipmondo/backend/router.py
Normal file
@ -0,0 +1,66 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, Request, Response
|
||||
|
||||
from app.modules.shipmondo.backend.service import shipmondo_service
|
||||
from app.modules.shipmondo.models.schemas import (
|
||||
ShipmondoBookingCreate,
|
||||
ShipmondoBookingListResponse,
|
||||
ShipmondoBookingResponse,
|
||||
ShipmondoBookingSubmitResponse,
|
||||
ShipmondoProductListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_id_from_request(request: Request) -> Optional[int]:
|
||||
raw_user_id = getattr(request.state, "user_id", None)
|
||||
try:
|
||||
return int(raw_user_id) if raw_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/shipmondo/config")
|
||||
async def shipmondo_config() -> dict:
|
||||
return {
|
||||
"enabled": shipmondo_service.enabled,
|
||||
"configured": shipmondo_service.configured,
|
||||
"read_only": shipmondo_service.read_only,
|
||||
"dry_run": shipmondo_service.dry_run,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/shipmondo/products", response_model=ShipmondoProductListResponse)
|
||||
async def list_products(country_code: str = Query(default="DK", min_length=2, max_length=2)):
|
||||
return {"items": await shipmondo_service.list_products(country_code)}
|
||||
|
||||
|
||||
@router.post("/shipmondo/bookings", response_model=ShipmondoBookingResponse)
|
||||
async def create_booking(payload: ShipmondoBookingCreate, request: Request):
|
||||
return shipmondo_service.create_booking_draft(payload, _user_id_from_request(request))
|
||||
|
||||
|
||||
@router.get("/shipmondo/bookings", response_model=ShipmondoBookingListResponse)
|
||||
async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)):
|
||||
return {"items": shipmondo_service.list_bookings(case_id)}
|
||||
|
||||
|
||||
@router.get("/shipmondo/bookings/{booking_ref}", response_model=ShipmondoBookingResponse)
|
||||
async def get_booking(booking_ref: str):
|
||||
return shipmondo_service.get_booking(booking_ref)
|
||||
|
||||
|
||||
@router.post("/shipmondo/bookings/{booking_ref}/submit", response_model=ShipmondoBookingSubmitResponse)
|
||||
async def submit_booking(booking_ref: str, request: Request):
|
||||
return await shipmondo_service.submit_booking(booking_ref, _user_id_from_request(request))
|
||||
|
||||
|
||||
@router.get("/shipmondo/bookings/{booking_ref}/label")
|
||||
async def download_label(booking_ref: str):
|
||||
return Response(
|
||||
content=shipmondo_service.get_label_pdf(booking_ref),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{booking_ref}.pdf"'},
|
||||
)
|
||||
388
app/modules/shipmondo/backend/service.py
Normal file
388
app/modules/shipmondo/backend/service.py
Normal file
@ -0,0 +1,388 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.modules.shipmondo.backend.api_client import ShipmondoApiClient
|
||||
from app.modules.shipmondo.models.schemas import ShipmondoBookingCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_dumps(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=_json_default)
|
||||
|
||||
|
||||
def _json_object(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except ValueError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _json_list(value: Any) -> List[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except ValueError:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("pkg_no", "tracking_number", "trackingNumber", "external_pkg_no"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
return str(value).strip()
|
||||
for parcel in payload.get("parcels") or []:
|
||||
if not isinstance(parcel, dict):
|
||||
continue
|
||||
value = parcel.get("pkg_no") or parcel.get("tracking_number")
|
||||
if value:
|
||||
return str(value).strip()
|
||||
values = parcel.get("pkg_nos")
|
||||
if isinstance(values, list) and values:
|
||||
return str(values[0]).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_tracking_url(payload: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("tracking_url", "trackingUrl", "carrier_tracking_url"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.startswith(("https://", "http://")):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def extract_price(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]:
|
||||
amount = _to_float(payload.get("price") or payload.get("total_amount") or payload.get("amount"))
|
||||
currency = payload.get("currency") or payload.get("currency_code")
|
||||
return amount, (str(currency).upper() if currency else ("DKK" if amount is not None else None))
|
||||
|
||||
|
||||
def extract_label_base64(payload: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("label_base64", "labelBase64", "label"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip() and not value.startswith(("http://", "https://")):
|
||||
return value.split(",", 1)[-1].strip()
|
||||
for parcel in payload.get("parcels") or []:
|
||||
if isinstance(parcel, dict):
|
||||
value = parcel.get("label_base64") or parcel.get("labelBase64")
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.split(",", 1)[-1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def build_shipmondo_payload(shipment: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sender = {
|
||||
"type": "sender",
|
||||
"name": settings.SHIPMONDO_SENDER_NAME.strip(),
|
||||
"attention": settings.SHIPMONDO_SENDER_ATTENTION.strip() or None,
|
||||
"address1": settings.SHIPMONDO_SENDER_ADDRESS1.strip(),
|
||||
"address2": settings.SHIPMONDO_SENDER_ADDRESS2.strip() or None,
|
||||
"postal_code": settings.SHIPMONDO_SENDER_POSTAL_CODE.strip(),
|
||||
"city": settings.SHIPMONDO_SENDER_CITY.strip(),
|
||||
"country_code": (settings.SHIPMONDO_SENDER_COUNTRY_CODE or "DK").strip().upper(),
|
||||
"email": settings.SHIPMONDO_SENDER_EMAIL.strip() or None,
|
||||
"phone": settings.SHIPMONDO_SENDER_PHONE.strip() or None,
|
||||
}
|
||||
receiver = {
|
||||
"type": "receiver",
|
||||
"name": shipment.get("company_name") or shipment["recipient_name"],
|
||||
"attention": shipment["recipient_name"] if shipment.get("company_name") else None,
|
||||
"address1": shipment["address_line1"],
|
||||
"address2": shipment.get("address_line2"),
|
||||
"postal_code": shipment["postal_code"],
|
||||
"city": shipment["city"],
|
||||
"country_code": shipment["country_code"],
|
||||
"email": shipment.get("email"),
|
||||
"phone": shipment.get("phone"),
|
||||
}
|
||||
parcels = []
|
||||
for index, parcel in enumerate(shipment.get("parcels") or [], start=1):
|
||||
item: Dict[str, Any] = {
|
||||
"quantity": 1,
|
||||
"weight": max(1, round(float(parcel["weight_kg"]) * 1000)),
|
||||
"content": parcel.get("description"),
|
||||
"internal_reference": f"{shipment['booking_ref']}-{index}",
|
||||
}
|
||||
for source, target in (("length_cm", "length"), ("width_cm", "width"), ("height_cm", "height")):
|
||||
if parcel.get(source) is not None:
|
||||
item[target] = max(1, round(float(parcel[source])))
|
||||
parcels.append(item)
|
||||
|
||||
return {
|
||||
"own_agreement": bool(shipment.get("own_agreement")),
|
||||
"label_format": "10x19_pdf",
|
||||
"print": False,
|
||||
"product_code": shipment["product_code"],
|
||||
"service_codes": ",".join(_json_list(shipment.get("service_codes"))),
|
||||
"automatic_select_service_point": bool(shipment.get("automatic_select_service_point")),
|
||||
"reference": f"Sag #{shipment['case_id']} · {shipment['booking_ref']}",
|
||||
"parties": [sender, receiver],
|
||||
"parcels": parcels,
|
||||
}
|
||||
|
||||
|
||||
class ShipmondoService:
|
||||
def __init__(self) -> None:
|
||||
self.client = ShipmondoApiClient()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(settings.SHIPMONDO_ENABLED)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return bool(settings.SHIPMONDO_READ_ONLY)
|
||||
|
||||
@property
|
||||
def dry_run(self) -> bool:
|
||||
return bool(settings.SHIPMONDO_DRY_RUN)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return self.client.configured
|
||||
|
||||
def _assert_enabled(self) -> None:
|
||||
if not self.enabled:
|
||||
raise HTTPException(status_code=503, detail="Shipmondo-integrationen er slået fra")
|
||||
|
||||
def _assert_sender_configured(self) -> None:
|
||||
required = {
|
||||
"navn": settings.SHIPMONDO_SENDER_NAME,
|
||||
"adresse": settings.SHIPMONDO_SENDER_ADDRESS1,
|
||||
"postnummer": settings.SHIPMONDO_SENDER_POSTAL_CODE,
|
||||
"by": settings.SHIPMONDO_SENDER_CITY,
|
||||
}
|
||||
missing = [label for label, value in required.items() if not str(value or "").strip()]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Shipmondo mangler afsenderens {', '.join(missing)} i konfigurationen",
|
||||
)
|
||||
|
||||
def _booking_ref(self) -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return f"SMD-{stamp}-{uuid4().hex[:8].upper()}"
|
||||
|
||||
def _validate_relations(self, payload: ShipmondoBookingCreate) -> None:
|
||||
if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,)):
|
||||
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
||||
if payload.customer_id and not execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,)):
|
||||
raise HTTPException(status_code=404, detail="Kunden findes ikke")
|
||||
if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)):
|
||||
raise HTTPException(status_code=404, detail="Kontakten findes ikke")
|
||||
|
||||
def _fetch_parcels(self, shipment_id: int) -> List[Dict[str, Any]]:
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT weight_kg, length_cm, width_cm, height_cm, description
|
||||
FROM shipmondo_shipment_parcels
|
||||
WHERE shipment_id = %s
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(shipment_id,),
|
||||
) or []
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mapped = dict(row)
|
||||
mapped["service_codes"] = [str(value) for value in _json_list(mapped.get("service_codes"))]
|
||||
mapped["parcels"] = self._fetch_parcels(int(mapped["id"]))
|
||||
api_response = _json_object(mapped.get("api_response"))
|
||||
mapped["tracking_number"] = mapped.get("tracking_number") or extract_tracking_number(api_response)
|
||||
mapped["tracking_url"] = mapped.get("tracking_url") or extract_tracking_url(api_response)
|
||||
if extract_label_base64(api_response):
|
||||
mapped["label_url"] = f"/api/v1/shipmondo/bookings/{mapped['booking_ref']}/label"
|
||||
else:
|
||||
label_url = api_response.get("label_url")
|
||||
mapped["label_url"] = label_url if isinstance(label_url, str) else None
|
||||
return mapped
|
||||
|
||||
async def list_products(self, country_code: str) -> List[Dict[str, Any]]:
|
||||
self._assert_enabled()
|
||||
if not self.configured:
|
||||
raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret")
|
||||
try:
|
||||
products = await self.client.list_products(country_code)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return [product for product in products if product.get("available", True)]
|
||||
|
||||
def create_booking_draft(self, payload: ShipmondoBookingCreate, user_id: Optional[int]) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
self._validate_relations(payload)
|
||||
booking_ref = self._booking_ref()
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO shipmondo_shipments (
|
||||
booking_ref, case_id, customer_id, contact_id, product_code, product_name,
|
||||
carrier_code, carrier_name, service_codes, shipment_status, own_agreement,
|
||||
automatic_select_service_point, recipient_name, company_name, address_line1,
|
||||
address_line2, postal_code, city, country_code, phone, email, dry_run,
|
||||
created_by_user_id, updated_by_user_id, api_payload, api_response
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 'draft', %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb
|
||||
) RETURNING *
|
||||
""",
|
||||
(
|
||||
booking_ref, payload.case_id, payload.customer_id, payload.contact_id,
|
||||
payload.product_code, payload.product_name, payload.carrier_code, payload.carrier_name,
|
||||
_json_dumps(payload.service_codes), payload.own_agreement,
|
||||
payload.automatic_select_service_point, payload.address.recipient_name,
|
||||
payload.address.company_name, payload.address.address_line1, payload.address.address_line2,
|
||||
payload.address.postal_code, payload.address.city, payload.address.country_code,
|
||||
payload.address.phone, payload.address.email, self.dry_run, user_id, user_id,
|
||||
_json_dumps(payload.model_dump(mode="json")), _json_dumps({"status": "draft_created"}),
|
||||
),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=500, detail="Shipmondo-draft kunne ikke oprettes")
|
||||
for parcel in payload.parcels:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO shipmondo_shipment_parcels
|
||||
(shipment_id, weight_kg, length_cm, width_cm, height_cm, description)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(row["id"], parcel.weight_kg, parcel.length_cm, parcel.width_cm, parcel.height_cm, parcel.description),
|
||||
)
|
||||
return self.get_booking(booking_ref)
|
||||
|
||||
def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
params: List[Any] = []
|
||||
where = "WHERE deleted_at IS NULL"
|
||||
if case_id is not None:
|
||||
where += " AND case_id = %s"
|
||||
params.append(case_id)
|
||||
rows = execute_query(
|
||||
f"SELECT * FROM shipmondo_shipments {where} ORDER BY created_at DESC LIMIT 200",
|
||||
tuple(params),
|
||||
) or []
|
||||
return [self._row_to_dict(dict(row)) for row in rows]
|
||||
|
||||
def get_booking(self, booking_ref: str) -> Dict[str, Any]:
|
||||
row = execute_query_single(
|
||||
"SELECT * FROM shipmondo_shipments WHERE booking_ref = %s AND deleted_at IS NULL",
|
||||
(booking_ref,),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Shipmondo-forsendelsen findes ikke")
|
||||
return self._row_to_dict(dict(row))
|
||||
|
||||
async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
shipment = self.get_booking(booking_ref)
|
||||
if shipment["shipment_status"] not in {"draft", "failed"}:
|
||||
raise HTTPException(status_code=409, detail="Kun drafts og fejlede bookinger kan sendes igen")
|
||||
if self.read_only:
|
||||
raise HTTPException(status_code=403, detail="Shipmondo er i skrivebeskyttet tilstand")
|
||||
self._assert_sender_configured()
|
||||
payload = build_shipmondo_payload(shipment)
|
||||
|
||||
if self.dry_run:
|
||||
api_response = {
|
||||
"id": f"dry-{uuid4().hex[:10]}",
|
||||
"pkg_no": f"SMD-DRY-{uuid4().hex[:10].upper()}",
|
||||
"price": "69.00",
|
||||
"currency": "DKK",
|
||||
"dry_run": True,
|
||||
}
|
||||
new_status = "submitted"
|
||||
else:
|
||||
if not self.configured:
|
||||
raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret")
|
||||
try:
|
||||
api_response = await self.client.create_shipment(payload)
|
||||
except Exception as exc:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE shipmondo_shipments
|
||||
SET shipment_status = 'failed', api_response = %s::jsonb, updated_by_user_id = %s
|
||||
WHERE booking_ref = %s
|
||||
""",
|
||||
(_json_dumps({"error": str(exc)}), user_id, booking_ref),
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"Shipmondo-booking fejlede: {exc}") from exc
|
||||
new_status = "booked"
|
||||
|
||||
tracking_number = extract_tracking_number(api_response)
|
||||
tracking_url = extract_tracking_url(api_response)
|
||||
total_amount, currency = extract_price(api_response)
|
||||
shipmondo_id = api_response.get("id")
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE shipmondo_shipments
|
||||
SET shipmondo_id = %s, shipment_status = %s, tracking_number = %s, tracking_url = %s,
|
||||
total_amount = %s, currency = %s, submitted_at = CURRENT_TIMESTAMP,
|
||||
api_payload = %s::jsonb, api_response = %s::jsonb, updated_by_user_id = %s
|
||||
WHERE booking_ref = %s
|
||||
""",
|
||||
(
|
||||
str(shipmondo_id) if shipmondo_id is not None else None, new_status,
|
||||
tracking_number, tracking_url, total_amount, currency,
|
||||
_json_dumps(payload), _json_dumps(api_response), user_id, booking_ref,
|
||||
),
|
||||
)
|
||||
booking = self.get_booking(booking_ref)
|
||||
return {
|
||||
"booking_ref": booking_ref,
|
||||
"status": new_status,
|
||||
"dry_run": self.dry_run,
|
||||
"tracking_number": tracking_number,
|
||||
"tracking_url": tracking_url,
|
||||
"label_url": booking.get("label_url"),
|
||||
"total_amount": total_amount,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
def get_label_pdf(self, booking_ref: str) -> bytes:
|
||||
booking = self.get_booking(booking_ref)
|
||||
row = execute_query_single(
|
||||
"SELECT api_response FROM shipmondo_shipments WHERE id = %s",
|
||||
(booking["id"],),
|
||||
)
|
||||
encoded = extract_label_base64(_json_object(row.get("api_response") if row else None))
|
||||
if not encoded:
|
||||
raise HTTPException(status_code=404, detail="Forsendelsen har endnu ingen PDF-label")
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(status_code=500, detail="Shipmondo-labelen kunne ikke læses") from exc
|
||||
|
||||
|
||||
shipmondo_service = ShipmondoService()
|
||||
1
app/modules/shipmondo/models/__init__.py
Normal file
1
app/modules/shipmondo/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shipmondo API models."""
|
||||
106
app/modules/shipmondo/models/schemas.py
Normal file
106
app/modules/shipmondo/models/schemas.py
Normal file
@ -0,0 +1,106 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
ShipmentStatus = Literal[
|
||||
"draft",
|
||||
"submitted",
|
||||
"booked",
|
||||
"in_transit",
|
||||
"delivered",
|
||||
"cancelled",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
class ShipmondoAddress(BaseModel):
|
||||
recipient_name: str = Field(min_length=2, max_length=150)
|
||||
company_name: Optional[str] = Field(default=None, max_length=150)
|
||||
address_line1: str = Field(min_length=2, max_length=200)
|
||||
address_line2: Optional[str] = Field(default=None, max_length=200)
|
||||
postal_code: str = Field(min_length=2, max_length=20)
|
||||
city: str = Field(min_length=2, max_length=120)
|
||||
country_code: str = Field(min_length=2, max_length=2)
|
||||
phone: Optional[str] = Field(default=None, max_length=50)
|
||||
email: Optional[str] = Field(default=None, max_length=150)
|
||||
|
||||
@field_validator("country_code")
|
||||
@classmethod
|
||||
def normalize_country_code(cls, value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class ShipmondoParcelInput(BaseModel):
|
||||
weight_kg: float = Field(gt=0, le=2000)
|
||||
length_cm: Optional[float] = Field(default=None, gt=0, le=400)
|
||||
width_cm: Optional[float] = Field(default=None, gt=0, le=400)
|
||||
height_cm: Optional[float] = Field(default=None, gt=0, le=400)
|
||||
description: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class ShipmondoBookingCreate(BaseModel):
|
||||
case_id: int = Field(gt=0)
|
||||
customer_id: Optional[int] = Field(default=None, gt=0)
|
||||
contact_id: Optional[int] = Field(default=None, gt=0)
|
||||
product_code: str = Field(min_length=1, max_length=80)
|
||||
product_name: Optional[str] = Field(default=None, max_length=180)
|
||||
carrier_code: Optional[str] = Field(default=None, max_length=80)
|
||||
carrier_name: Optional[str] = Field(default=None, max_length=120)
|
||||
service_codes: List[str] = Field(default_factory=list, max_length=30)
|
||||
own_agreement: bool = False
|
||||
automatic_select_service_point: bool = False
|
||||
address: ShipmondoAddress
|
||||
parcels: List[ShipmondoParcelInput] = Field(min_length=1, max_length=30)
|
||||
|
||||
@field_validator("service_codes")
|
||||
@classmethod
|
||||
def normalize_service_codes(cls, values: List[str]) -> List[str]:
|
||||
return list(dict.fromkeys(value.strip().upper() for value in values if value.strip()))
|
||||
|
||||
|
||||
class ShipmondoBookingResponse(BaseModel):
|
||||
id: int
|
||||
booking_ref: str
|
||||
case_id: int
|
||||
customer_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
product_code: str
|
||||
product_name: Optional[str] = None
|
||||
carrier_code: Optional[str] = None
|
||||
carrier_name: Optional[str] = None
|
||||
service_codes: List[str] = Field(default_factory=list)
|
||||
shipment_status: ShipmentStatus
|
||||
recipient_name: str
|
||||
city: str
|
||||
country_code: str
|
||||
tracking_number: Optional[str] = None
|
||||
tracking_url: Optional[str] = None
|
||||
label_url: Optional[str] = None
|
||||
total_amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
dry_run: bool
|
||||
submitted_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
parcels: List[ShipmondoParcelInput] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ShipmondoBookingListResponse(BaseModel):
|
||||
items: List[ShipmondoBookingResponse]
|
||||
|
||||
|
||||
class ShipmondoBookingSubmitResponse(BaseModel):
|
||||
booking_ref: str
|
||||
status: ShipmentStatus
|
||||
dry_run: bool
|
||||
tracking_number: Optional[str] = None
|
||||
tracking_url: Optional[str] = None
|
||||
label_url: Optional[str] = None
|
||||
total_amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
|
||||
|
||||
class ShipmondoProductListResponse(BaseModel):
|
||||
items: List[Dict[str, Any]]
|
||||
@ -324,11 +324,120 @@ def _calculate_sales_price_with_margin(base_price: Any, margin_percent: Optional
|
||||
return round(price, 2)
|
||||
|
||||
|
||||
def _first_apigw_value(product: Dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
value = product.get(key)
|
||||
if value is not None and (not isinstance(value, str) or value.strip()):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _unwrap_apigw_product(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Accept both the Hub wrapper and common API Gateway item wrappers."""
|
||||
product: Any = payload
|
||||
identity_keys = (
|
||||
"product_name",
|
||||
"name",
|
||||
"productName",
|
||||
"title",
|
||||
"sku",
|
||||
"supplier_sku",
|
||||
"item_number",
|
||||
"part_number",
|
||||
"ean",
|
||||
"gtin",
|
||||
"barcode",
|
||||
)
|
||||
for _ in range(4):
|
||||
if isinstance(product, str):
|
||||
try:
|
||||
product = json.loads(product)
|
||||
except (TypeError, ValueError):
|
||||
break
|
||||
if not isinstance(product, dict):
|
||||
break
|
||||
if any(_first_apigw_value(product, key) is not None for key in identity_keys):
|
||||
break
|
||||
|
||||
nested = next(
|
||||
(
|
||||
product.get(key)
|
||||
for key in ("product", "item", "result", "data")
|
||||
if isinstance(product.get(key), (dict, str))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if nested is None:
|
||||
break
|
||||
product = nested
|
||||
|
||||
return dict(product) if isinstance(product, dict) else {}
|
||||
|
||||
|
||||
def _normalize_apigw_product(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize supplier variants before rendering or importing a product."""
|
||||
product = _unwrap_apigw_product(payload)
|
||||
normalized = dict(product)
|
||||
|
||||
supplier = product.get("supplier") if isinstance(product.get("supplier"), dict) else {}
|
||||
primary_category = product.get("primary_category") if isinstance(product.get("primary_category"), dict) else {}
|
||||
|
||||
aliases = {
|
||||
"product_name": _first_apigw_value(
|
||||
product,
|
||||
"product_name",
|
||||
"name",
|
||||
"productName",
|
||||
"title",
|
||||
"description",
|
||||
"short_description",
|
||||
),
|
||||
"sku": _first_apigw_value(product, "sku", "supplier_sku", "item_number", "part_number"),
|
||||
"ean": _first_apigw_value(product, "ean", "ean13", "gtin", "barcode"),
|
||||
"price": _first_apigw_value(product, "price", "supplier_price", "net_price", "cost_price", "unit_price"),
|
||||
"currency": _first_apigw_value(product, "currency", "supplier_currency"),
|
||||
"stock_qty": _first_apigw_value(product, "stock_qty", "stock", "quantity", "available_stock"),
|
||||
"category": (
|
||||
_first_apigw_value(product, "category", "legacy_category")
|
||||
or primary_category.get("name")
|
||||
),
|
||||
"supplier_name": _first_apigw_value(product, "supplier_name") or supplier.get("name"),
|
||||
"supplier_code": _first_apigw_value(product, "supplier_code") or supplier.get("code"),
|
||||
"product_url": _first_apigw_value(
|
||||
product,
|
||||
"product_url",
|
||||
"product_link",
|
||||
"supplier_product_url",
|
||||
"supplier_url",
|
||||
"url",
|
||||
),
|
||||
}
|
||||
for key, value in aliases.items():
|
||||
if value is not None and (not isinstance(value, str) or value.strip()):
|
||||
normalized[key] = value.strip() if isinstance(value, str) else value
|
||||
|
||||
if not str(normalized.get("product_name") or "").strip():
|
||||
sku = str(normalized.get("sku") or normalized.get("ean") or "").strip()
|
||||
descriptor = str(
|
||||
normalized.get("manufacturer")
|
||||
or normalized.get("supplier_name")
|
||||
or "Produkt"
|
||||
).strip()
|
||||
if sku:
|
||||
normalized["product_name"] = f"{descriptor} · {sku}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
product = payload.get("product") or payload
|
||||
name = (product.get("product_name") or product.get("name") or "").strip()
|
||||
product = _normalize_apigw_product(payload)
|
||||
name = str(product.get("product_name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="product_name is required")
|
||||
logger.warning("APIGW product import rejected because no usable name or item code was present; keys=%s", sorted(product.keys()))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Produktet mangler både navn og varenummer og kan derfor ikke importeres",
|
||||
)
|
||||
|
||||
supplier_code = product.get("supplier_code")
|
||||
sku = product.get("sku")
|
||||
@ -343,7 +452,7 @@ def _import_apigw_product_to_local(payload: Dict[str, Any], customer_id: Optiona
|
||||
_upsert_product_supplier(existing_by_sku["id"], product, source="gateway")
|
||||
return existing_by_sku
|
||||
|
||||
ean = (product.get("ean") or "").strip()
|
||||
ean = str(product.get("ean") or "").strip()
|
||||
if ean:
|
||||
existing_by_ean = execute_query_single(
|
||||
"SELECT * FROM products WHERE ean = %s AND deleted_at IS NULL",
|
||||
@ -461,7 +570,13 @@ async def search_apigw_products(
|
||||
)
|
||||
data = await response.json()
|
||||
|
||||
if q and isinstance(data, dict) and isinstance(data.get("products"), list):
|
||||
if isinstance(data, dict) and isinstance(data.get("products"), list):
|
||||
data["products"] = [
|
||||
_normalize_apigw_product(product)
|
||||
for product in data["products"]
|
||||
if isinstance(product, dict)
|
||||
]
|
||||
if q:
|
||||
normalized_query, tokens = _normalize_query(q)
|
||||
data["products"].sort(
|
||||
key=lambda product: _score_apigw_product(product, normalized_query, tokens),
|
||||
@ -490,7 +605,7 @@ async def search_or_create_product_from_apigw(
|
||||
):
|
||||
"""
|
||||
Local-first product lookup by EAN/SKU-like code.
|
||||
If not found locally, search APIGateway and optionally auto-create locally from best match.
|
||||
If not found locally, search API Gateway and optionally auto-create locally from best match.
|
||||
"""
|
||||
search_code = (code or "").strip()
|
||||
if not search_code:
|
||||
@ -532,6 +647,11 @@ async def search_or_create_product_from_apigw(
|
||||
|
||||
products = data.get("products") if isinstance(data, dict) else []
|
||||
products = products if isinstance(products, list) else []
|
||||
products = [
|
||||
_normalize_apigw_product(product)
|
||||
for product in products
|
||||
if isinstance(product, dict)
|
||||
]
|
||||
best_match = _pick_best_apigw_match(products, search_code)
|
||||
if not best_match:
|
||||
return {
|
||||
@ -539,7 +659,7 @@ async def search_or_create_product_from_apigw(
|
||||
"source": "apigateway",
|
||||
"created": False,
|
||||
"query": search_code,
|
||||
"message": "Ingen match i APIGateway",
|
||||
"message": "Ingen match i API Gateway",
|
||||
}
|
||||
|
||||
if not auto_create:
|
||||
|
||||
@ -125,14 +125,22 @@
|
||||
|
||||
.gateway-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--products-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--products-card);
|
||||
box-shadow: 0 5px 18px rgba(15, 76, 117, 0.06);
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.gateway-result:hover {
|
||||
border-color: rgba(15, 76, 117, 0.35);
|
||||
box-shadow: 0 10px 26px rgba(15, 76, 117, 0.11);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.gateway-result h6 {
|
||||
@ -145,6 +153,101 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gateway-result-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gateway-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 7px 18px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.gateway-info-item {
|
||||
min-width: 0;
|
||||
color: var(--products-ink);
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gateway-info-label {
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
color: var(--products-ink-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gateway-result-actions {
|
||||
display: flex;
|
||||
min-width: 150px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gateway-price {
|
||||
color: var(--products-ink);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gateway-catalog-dialog {
|
||||
width: min(1680px, calc(100vw - 32px));
|
||||
max-width: none;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.gateway-catalog-modal {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 30px 80px rgba(15, 42, 66, 0.28);
|
||||
}
|
||||
|
||||
.gateway-catalog-modal .modal-header {
|
||||
padding: 18px 24px;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #0f4c75 0%, #176b8f 55%, #1f7fa4 100%);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.gateway-catalog-modal .modal-header .btn-close {
|
||||
filter: invert(1) grayscale(1) brightness(2);
|
||||
}
|
||||
|
||||
.gateway-catalog-modal .modal-body {
|
||||
max-height: calc(100vh - 100px);
|
||||
padding: 22px 24px 28px;
|
||||
overflow-y: auto;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
|
||||
.gateway-search-panel {
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--products-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 5px 18px rgba(15, 76, 117, 0.05);
|
||||
}
|
||||
|
||||
.gateway-results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gateway-results-grid .gateway-result {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.products-card .card-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--products-border);
|
||||
@ -184,6 +287,29 @@
|
||||
.products-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.gateway-result {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gateway-result-actions {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gateway-catalog-dialog {
|
||||
width: calc(100vw - 16px);
|
||||
margin: 8px auto;
|
||||
}
|
||||
|
||||
.gateway-catalog-modal .modal-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.gateway-results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@ -202,7 +328,7 @@
|
||||
<i class="bi bi-plus-circle"></i> Opret Produkt
|
||||
</button>
|
||||
<button class="btn btn-outline-primary ms-2" onclick="openGatewaySearchModal()">
|
||||
<i class="bi bi-cloud-download"></i> Import fra gateway
|
||||
<i class="bi bi-search"></i> Søg hos grossister
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -234,8 +360,8 @@
|
||||
<button class="btn btn-outline-primary" onclick="applyLocalProductSearch()">
|
||||
<i class="bi bi-search"></i> Søg
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="searchAndCreateByGatewayCode()" id="gatewayBarcodeSyncBtn">
|
||||
<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)
|
||||
<button class="btn btn-primary" onclick="searchGatewayCatalogFromLocalQuery()" id="gatewayCatalogSearchBtn">
|
||||
<i class="bi bi-search"></i> Søg hos alle grossister
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="clearLocalProductSearch()">
|
||||
<i class="bi bi-x"></i> Nulstil
|
||||
@ -289,46 +415,58 @@
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="gatewaySearchModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-dialog gateway-catalog-dialog">
|
||||
<div class="modal-content gateway-catalog-modal">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-cloud-arrow-down"></i> Import fra API Gateway</h5>
|
||||
<h5 class="modal-title"><i class="bi bi-cloud"></i> Grossistkatalog</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Søg efter produktnavn...">
|
||||
<div class="gateway-search-panel">
|
||||
<div class="alert alert-info py-2 mb-3" role="status">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Søgningen opretter ikke varer i Hub'en. Der søges hos alle grossister, medmindre du vælger en bestemt leverandør.
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Supplier code">
|
||||
<div class="row g-2">
|
||||
<div class="col-lg-5">
|
||||
<label class="form-label small fw-semibold" for="gatewayModalQuery">Søgeord, varenummer eller EAN</label>
|
||||
<input type="text" class="form-control" id="gatewayModalQuery" placeholder="Fx Lenovo dockingstation eller 570...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-primary w-100" onclick="searchGatewayProducts(true)">
|
||||
<i class="bi bi-search"></i> Søg
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<label class="form-label small fw-semibold" for="gatewayModalSupplier">Grossist</label>
|
||||
<input type="text" class="form-control" id="gatewayModalSupplier" placeholder="Alle grossister">
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-semibold" for="gatewayModalManufacturer">Producent</label>
|
||||
<input type="text" class="form-control" id="gatewayModalManufacturer" placeholder="Alle producenter">
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 d-grid align-items-end">
|
||||
<button class="btn btn-primary" onclick="searchGatewayProducts(true)">
|
||||
<i class="bi bi-search me-1"></i> Søg i kataloget
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" id="gatewayModalMinPrice" placeholder="Min pris">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<label class="form-label small fw-semibold" for="gatewayModalCategory">Kategori</label>
|
||||
<input type="text" class="form-control" id="gatewayModalCategory" placeholder="Alle kategorier">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" id="gatewayModalMaxPrice" placeholder="Max pris">
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-semibold" for="gatewayModalMinPrice">Pris fra</label>
|
||||
<input type="number" class="form-control" id="gatewayModalMinPrice" placeholder="0">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check">
|
||||
<div class="col-lg-2 col-md-3">
|
||||
<label class="form-label small fw-semibold" for="gatewayModalMaxPrice">Pris til</label>
|
||||
<input type="number" class="form-control" id="gatewayModalMaxPrice" placeholder="Ingen grænse">
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 d-flex align-items-end pb-2">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="gatewayModalInStock">
|
||||
<label class="form-check-label" for="gatewayModalInStock">Kun på lager</label>
|
||||
<label class="form-check-label" for="gatewayModalInStock">Vis kun varer på lager</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="gatewayModalCategory" placeholder="Kategori">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="gatewayModalManufacturer" placeholder="Producent">
|
||||
</div>
|
||||
</div>
|
||||
<div id="gatewayResults">
|
||||
<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>
|
||||
<div class="text-center text-muted py-4">Søg på navn, varenummer, EAN, producent eller kategori.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -451,7 +589,11 @@ async function loadProducts() {
|
||||
const params = buildLocalProductParams();
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/api/v1/products?${queryString}` : '/api/v1/products';
|
||||
const products = await fetch(url).then(r => r.json());
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
const products = await response.json().catch(() => []);
|
||||
if (!response.ok) {
|
||||
throw new Error(products?.detail || `Produkter kunne ikke hentes (HTTP ${response.status})`);
|
||||
}
|
||||
localProductsState.products = Array.isArray(products) ? products : [];
|
||||
localProductsState.page = 1;
|
||||
localProductsState.query = document.getElementById('localSearchQuery')?.value.trim() || '';
|
||||
@ -603,58 +745,10 @@ function showLocalProductMessage(message, level = 'info') {
|
||||
meta.textContent = `${prefix}${message}`;
|
||||
}
|
||||
|
||||
async function searchAndCreateByGatewayCode() {
|
||||
function searchGatewayCatalogFromLocalQuery() {
|
||||
const queryInput = document.getElementById('localSearchQuery');
|
||||
const button = document.getElementById('gatewayBarcodeSyncBtn');
|
||||
const code = queryInput ? queryInput.value.trim() : '';
|
||||
|
||||
if (!code) {
|
||||
alert('Skriv EAN eller stregkode i søgefeltet først');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHtml = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Søger...';
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ code, auto_create: 'true' });
|
||||
const response = await fetch(`/api/v1/products/search/apigateway-sync?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.detail || 'Sogning i APIGateway fejlede');
|
||||
}
|
||||
|
||||
if (!body.found) {
|
||||
showLocalProductMessage('Ingen match i APIGateway for den strengkode', 'error');
|
||||
alert('Ingen produkt fundet i APIGateway for den strengkode');
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.source === 'local') {
|
||||
showLocalProductMessage('Produkt fundet lokalt', 'success');
|
||||
} else if (body.created) {
|
||||
showLocalProductMessage('Produkt hentet fra APIGateway og oprettet lokalt', 'success');
|
||||
} else {
|
||||
showLocalProductMessage('Produkt fundet via APIGateway', 'info');
|
||||
}
|
||||
|
||||
await loadProducts();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showLocalProductMessage(error.message || 'Sogning fejlede', 'error');
|
||||
alert(error.message || 'Sogning fejlede');
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = oldHtml || '<i class="bi bi-upc-scan"></i> Søg i APIGateway (stregkode)';
|
||||
}
|
||||
}
|
||||
const query = queryInput ? queryInput.value.trim() : '';
|
||||
openGatewaySearchModal(query, Boolean(query));
|
||||
}
|
||||
|
||||
function clearLocalProductSearch() {
|
||||
@ -719,7 +813,7 @@ function setupLocalSearchEvents() {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (id === 'localSearchQuery' && (event.ctrlKey || event.metaKey)) {
|
||||
searchAndCreateByGatewayCode();
|
||||
searchGatewayCatalogFromLocalQuery();
|
||||
} else {
|
||||
applyLocalProductSearch();
|
||||
}
|
||||
@ -802,15 +896,15 @@ async function createProduct() {
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
function openGatewaySearchModal(autoSearch = false) {
|
||||
document.getElementById('gatewayModalQuery').value = '';
|
||||
function openGatewaySearchModal(initialQuery = '', autoSearch = false) {
|
||||
document.getElementById('gatewayModalQuery').value = initialQuery;
|
||||
document.getElementById('gatewayModalSupplier').value = '';
|
||||
document.getElementById('gatewayModalCategory').value = '';
|
||||
document.getElementById('gatewayModalManufacturer').value = '';
|
||||
document.getElementById('gatewayModalMinPrice').value = '';
|
||||
document.getElementById('gatewayModalMaxPrice').value = '';
|
||||
document.getElementById('gatewayModalInStock').checked = false;
|
||||
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg efter produkter for at importere.</div>';
|
||||
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-muted py-4">Søg på navn, varenummer, EAN, producent eller kategori.</div>';
|
||||
new bootstrap.Modal(document.getElementById('gatewaySearchModal')).show();
|
||||
if (autoSearch) {
|
||||
searchGatewayProducts(true);
|
||||
@ -819,7 +913,7 @@ function openGatewaySearchModal(autoSearch = false) {
|
||||
|
||||
async function searchGatewayProducts(fromModal = false) {
|
||||
if (!fromModal) {
|
||||
openGatewaySearchModal(true);
|
||||
openGatewaySearchModal('', true);
|
||||
return;
|
||||
}
|
||||
const queryInput = document.getElementById('gatewayModalQuery');
|
||||
@ -835,12 +929,13 @@ async function searchGatewayProducts(fromModal = false) {
|
||||
const category = categoryInput ? categoryInput.value.trim() : '';
|
||||
const manufacturer = manufacturerInput ? manufacturerInput.value.trim() : '';
|
||||
const inStock = inStockInput ? inStockInput.checked : false;
|
||||
if (!q && !supplier) {
|
||||
alert('Angiv søgeord eller leverandørkode');
|
||||
if (!q && !supplier && !category && !manufacturer && !inStock && !minPrice && !maxPrice) {
|
||||
alert('Angiv mindst ét søgekriterium');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('per_page', '100');
|
||||
if (q) params.append('q', q);
|
||||
if (supplier) params.append('supplier_code', supplier);
|
||||
if (category) params.append('category', category);
|
||||
@ -855,18 +950,19 @@ async function searchGatewayProducts(fromModal = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`);
|
||||
const res = await fetch(`/api/v1/products/apigateway/search?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || 'Gateway-søgning fejlede');
|
||||
throw new Error(data.detail || `Gateway-søgning fejlede (HTTP ${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const products = Array.isArray(data.products) ? data.products : [];
|
||||
setGatewayResults(products, q);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${e.message || 'Fejl ved søgning'}</div>`;
|
||||
resultsContainer.innerHTML = `<div class="text-center text-danger py-4">${escapeHtml(e.message || 'Fejl ved søgning')}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -890,6 +986,16 @@ function escapeHtml(value) {
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function safeGatewayUrl(value) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const url = new URL(String(value), window.location.origin);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTokens(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
@ -937,12 +1043,91 @@ function highlightTokens(text, tokens) {
|
||||
}
|
||||
|
||||
function setGatewayResults(products, query) {
|
||||
gatewayResultsState.products = products;
|
||||
gatewayResultsState.products = products.map(normalizeGatewayProduct);
|
||||
gatewayResultsState.query = query || '';
|
||||
gatewayResultsState.page = 1;
|
||||
renderGatewayResults();
|
||||
}
|
||||
|
||||
function normalizeGatewayProduct(product) {
|
||||
const normalized = { ...(product || {}) };
|
||||
normalized.product_name = normalized.product_name
|
||||
|| normalized.name
|
||||
|| normalized.productName
|
||||
|| normalized.title
|
||||
|| normalized.description
|
||||
|| (normalized.sku ? `Produkt · ${normalized.sku}` : 'Unavngivet produkt');
|
||||
normalized.category = normalized.category
|
||||
|| normalized.legacy_category
|
||||
|| normalized.primary_category?.name
|
||||
|| '';
|
||||
normalized.sku = normalized.sku || normalized.supplier_sku || normalized.item_number || normalized.part_number || '';
|
||||
normalized.ean = normalized.ean || normalized.ean13 || normalized.gtin || normalized.barcode || '';
|
||||
normalized.supplier_name = normalized.supplier_name || normalized.supplier?.name || '';
|
||||
normalized.supplier_code = normalized.supplier_code || normalized.supplier?.code || '';
|
||||
normalized.stock_qty = normalized.stock_qty ?? normalized.stock ?? normalized.quantity ?? normalized.available_stock;
|
||||
normalized.stock_status = normalized.stock_status || normalized.availability || normalized.inventory_status || '';
|
||||
normalized.currency = normalized.currency || normalized.supplier_currency || 'DKK';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatGatewayCurrency(amount, currency = 'DKK') {
|
||||
const numericAmount = Number(amount);
|
||||
const safeAmount = Number.isFinite(numericAmount) ? numericAmount : 0;
|
||||
const safeCurrency = /^[A-Z]{3}$/.test(String(currency || '').toUpperCase())
|
||||
? String(currency).toUpperCase()
|
||||
: 'DKK';
|
||||
try {
|
||||
return new Intl.NumberFormat('da-DK', {
|
||||
style: 'currency',
|
||||
currency: safeCurrency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(safeAmount);
|
||||
} catch (_error) {
|
||||
return `${safeAmount.toFixed(2)} ${safeCurrency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function gatewayInfoItem(label, value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return '';
|
||||
return `<div class="gateway-info-item"><span class="gateway-info-label">${escapeHtml(label)}</span>${escapeHtml(value)}</div>`;
|
||||
}
|
||||
|
||||
function renderGatewayProductCard(product, tokens) {
|
||||
const encoded = encodeURIComponent(JSON.stringify(product));
|
||||
const supplierUrl = safeGatewayUrl(product.product_url || product.url || product.product_link || product.supplier_url || product.supplier_product_url);
|
||||
const name = highlightTokens(product.product_name || product.name || '', tokens);
|
||||
const stockParts = [];
|
||||
if (product.stock_qty !== null && product.stock_qty !== undefined && product.stock_qty !== '') {
|
||||
stockParts.push(`${product.stock_qty} stk.`);
|
||||
}
|
||||
if (product.stock_status) stockParts.push(product.stock_status);
|
||||
const supplier = [product.supplier_name, product.supplier_code].filter(Boolean).join(' · ');
|
||||
const details = [
|
||||
gatewayInfoItem('Grossist', supplier),
|
||||
gatewayInfoItem('Varenummer', product.sku),
|
||||
gatewayInfoItem('EAN / GTIN', product.ean),
|
||||
gatewayInfoItem('Producent', product.manufacturer),
|
||||
gatewayInfoItem('Kategori', product.category),
|
||||
gatewayInfoItem('Lager', stockParts.join(' · '))
|
||||
].join('');
|
||||
|
||||
return `
|
||||
<div class="gateway-result">
|
||||
<div class="gateway-result-main">
|
||||
<h6>${name}</h6>
|
||||
<div class="gateway-info-grid">${details || gatewayInfoItem('Oplysninger', 'Ingen yderligere produktdata')}</div>
|
||||
${supplierUrl ? `<div class="gateway-meta mt-2"><a href="${escapeHtml(supplierUrl)}" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Se varen hos grossisten</a></div>` : ''}
|
||||
</div>
|
||||
<div class="gateway-result-actions">
|
||||
<div class="gateway-price">${formatGatewayCurrency(product.price, product.currency)}</div>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" data-product="${encoded}" onclick="importGatewayProductFromAttr(this)">Importér til Hub</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function changeGatewaySort(value) {
|
||||
gatewayResultsState.sort = value;
|
||||
gatewayResultsState.page = 1;
|
||||
@ -1050,67 +1235,29 @@ function renderGatewayResults() {
|
||||
const bestMatchBlock = bestMatches.length ? `
|
||||
<div class="mb-3">
|
||||
<div class="fw-semibold mb-2">Bedste match</div>
|
||||
${bestMatches.map(product => {
|
||||
const encoded = encodeURIComponent(JSON.stringify(product));
|
||||
const supplierUrl = product.product_url || product.url || product.product_link || product.supplier_url || product.supplier_product_url;
|
||||
const name = highlightTokens(product.product_name || product.name || '', tokens);
|
||||
const sku = product.sku ? `<span class="badge bg-light text-dark border">${escapeHtml(product.sku)}</span>` : '';
|
||||
const manufacturer = product.manufacturer ? `<span class="badge bg-light text-dark border">${escapeHtml(product.manufacturer)}</span>` : '';
|
||||
const supplier = product.supplier_name ? `<span class="badge bg-light text-dark border">${escapeHtml(product.supplier_name)}</span>` : '';
|
||||
const category = product.category ? `<span class="text-muted small">${escapeHtml(product.category)}</span>` : '';
|
||||
const stock = product.stock_status ? `<span class="text-muted small">• ${escapeHtml(product.stock_status)}</span>` : '';
|
||||
return `
|
||||
<div class="gateway-result">
|
||||
<div>
|
||||
<h6 class="mb-1">${name}</h6>
|
||||
<div class="gateway-meta d-flex flex-wrap gap-2">${sku}${manufacturer}${supplier}</div>
|
||||
<div class="gateway-meta">${category} ${stock}</div>
|
||||
${supplierUrl ? `<div class="gateway-meta"><a href="${supplierUrl}" target="_blank" rel="noopener">Se hos grosist</a></div>` : ''}
|
||||
<div class="gateway-results-grid">
|
||||
${bestMatches.map(product => renderGatewayProductCard(product, tokens)).join('')}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="fw-semibold">${formatCurrency(product.price || 0)}</div>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" data-product="${encoded}" onclick="importGatewayProductFromAttr(this)">Importer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div class="fw-semibold mb-2">Alle resultater</div>
|
||||
` : '';
|
||||
|
||||
const rows = visibleItems.map(product => {
|
||||
const encoded = encodeURIComponent(JSON.stringify(product));
|
||||
const supplierUrl = product.product_url || product.url || product.product_link || product.supplier_url || product.supplier_product_url;
|
||||
const name = highlightTokens(product.product_name || product.name || '', tokens);
|
||||
const sku = product.sku ? `<span class="badge bg-light text-dark border">${escapeHtml(product.sku)}</span>` : '';
|
||||
const manufacturer = product.manufacturer ? `<span class="badge bg-light text-dark border">${escapeHtml(product.manufacturer)}</span>` : '';
|
||||
const supplier = product.supplier_name ? `<span class="badge bg-light text-dark border">${escapeHtml(product.supplier_name)}</span>` : '';
|
||||
const category = product.category ? `<span class="text-muted small">${escapeHtml(product.category)}</span>` : '';
|
||||
const stock = product.stock_status ? `<span class="text-muted small">• ${escapeHtml(product.stock_status)}</span>` : '';
|
||||
return `
|
||||
<div class="gateway-result">
|
||||
<div>
|
||||
<h6 class="mb-1">${name}</h6>
|
||||
<div class="gateway-meta d-flex flex-wrap gap-2">${sku}${manufacturer}${supplier}</div>
|
||||
<div class="gateway-meta">${category} ${stock}</div>
|
||||
${supplierUrl ? `<div class="gateway-meta"><a href="${supplierUrl}" target="_blank" rel="noopener">Se hos grosist</a></div>` : ''}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="fw-semibold">${formatCurrency(product.price || 0)}</div>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" data-product="${encoded}" onclick="importGatewayProductFromAttr(this)">Importer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
const rows = visibleItems.map(product => renderGatewayProductCard(product, tokens)).join('');
|
||||
|
||||
resultsContainer.innerHTML = header + bestMatchBlock + rows;
|
||||
resultsContainer.innerHTML = header + bestMatchBlock + `<div class="gateway-results-grid">${rows}</div>`;
|
||||
}
|
||||
|
||||
async function importGatewayProduct(product) {
|
||||
async function importGatewayProduct(product, button = null) {
|
||||
const oldHtml = button ? button.innerHTML : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Importerer...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/v1/products/apigateway/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ product })
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -1122,6 +1269,11 @@ async function importGatewayProduct(product) {
|
||||
document.getElementById('gatewayResults').innerHTML = '<div class="text-center text-success py-4">Produkt importeret.</div>';
|
||||
} catch (e) {
|
||||
alert(e.message || e);
|
||||
} finally {
|
||||
if (button?.isConnected) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = oldHtml;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1130,7 +1282,7 @@ function importGatewayProductFromAttr(button) {
|
||||
if (!payload) return;
|
||||
try {
|
||||
const product = JSON.parse(decodeURIComponent(payload));
|
||||
importGatewayProduct(product);
|
||||
importGatewayProduct(product, button);
|
||||
} catch (e) {
|
||||
alert('Kunne ikke læse produktdata');
|
||||
}
|
||||
|
||||
@ -679,6 +679,9 @@
|
||||
.global-bottom-bar .bb-panel-tasks li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.global-bottom-bar .bb-panel-empty { min-height: 140px; display: grid; place-items: center; text-align: center; color: var(--text-secondary); }
|
||||
.global-bottom-bar .bb-panel-empty i { display: block; font-size: 1.8rem; color: color-mix(in srgb, var(--accent) 55%, var(--text-secondary)); margin-bottom: 0.35rem; }
|
||||
.global-bottom-bar .bb-location-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.5rem; overflow-y: auto; min-height: 0; padding-right: 0.25rem; }
|
||||
.global-bottom-bar .bb-location-card { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; min-height: 58px; padding: 0.55rem 0.65rem; color: inherit; text-decoration: none; border: 1px solid rgba(var(--text-primary-rgb), 0.1); border-radius: 10px; background: var(--bg-card); }
|
||||
.global-bottom-bar .bb-location-card:hover { color: inherit; border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 5%, var(--bg-card)); }
|
||||
.global-bottom-bar .bb-live-indicator { width: 0.58rem; height: 0.58rem; border-radius: 50%; background: #198754; box-shadow: 0 0 0 5px rgba(25, 135, 84, 0.12); display: inline-block; margin-right: 0.55rem; }
|
||||
.global-bottom-bar .bb-detail-line {
|
||||
min-height: 28px;
|
||||
@ -1458,6 +1461,7 @@
|
||||
<button class="bb-tab-btn" type="button" data-bb-tab="messages" role="tab" aria-selected="false"><i class="bi bi-chat-dots"></i> Beskeder</button>
|
||||
<button class="bb-tab-btn" type="button" data-bb-tab="tasks" role="tab" aria-selected="false"><i class="bi bi-calendar-check"></i> Opgaver</button>
|
||||
<button class="bb-tab-btn" type="button" data-bb-tab="notes" role="tab" aria-selected="false"><i class="bi bi-journal-text"></i> Noter</button>
|
||||
<button class="bb-tab-btn" type="button" data-bb-tab="locations" role="tab" aria-selected="false"><i class="bi bi-geo-alt"></i> Lokationer</button>
|
||||
<!-- Vises kun for chefer, men her i markup -->
|
||||
<button class="bb-tab-btn" type="button" data-bb-tab="boss" role="tab" aria-selected="false"><i class="bi bi-diagram-3"></i> Sagsfordeling</button>
|
||||
</div>
|
||||
@ -1586,7 +1590,7 @@ if (bmcOriginalFetch) {
|
||||
<script src="/static/js/telefoni.js?v=2.5"></script>
|
||||
<script src="/static/js/sms.js?v=1.1"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.4"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.64"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.65"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||
|
||||
@ -62,6 +62,22 @@ services:
|
||||
- FEDEX_API_SECRET=${FEDEX_API_SECRET}
|
||||
- FEDEX_ACCOUNT_NUMBER=${FEDEX_ACCOUNT_NUMBER}
|
||||
- FEDEX_BASE_URL=${FEDEX_BASE_URL}
|
||||
- SHIPMONDO_ENABLED=${SHIPMONDO_ENABLED:-false}
|
||||
- SHIPMONDO_READ_ONLY=${SHIPMONDO_READ_ONLY:-true}
|
||||
- SHIPMONDO_DRY_RUN=${SHIPMONDO_DRY_RUN:-true}
|
||||
- SHIPMONDO_API_BASE_URL=${SHIPMONDO_API_BASE_URL:-https://app.shipmondo.com/api/public/v3}
|
||||
- SHIPMONDO_API_USER=${SHIPMONDO_API_USER:-}
|
||||
- SHIPMONDO_API_KEY=${SHIPMONDO_API_KEY:-}
|
||||
- SHIPMONDO_TIMEOUT_SECONDS=${SHIPMONDO_TIMEOUT_SECONDS:-30}
|
||||
- SHIPMONDO_SENDER_NAME=${SHIPMONDO_SENDER_NAME:-BMC Networks}
|
||||
- SHIPMONDO_SENDER_ATTENTION=${SHIPMONDO_SENDER_ATTENTION:-}
|
||||
- SHIPMONDO_SENDER_ADDRESS1=${SHIPMONDO_SENDER_ADDRESS1:-}
|
||||
- SHIPMONDO_SENDER_ADDRESS2=${SHIPMONDO_SENDER_ADDRESS2:-}
|
||||
- SHIPMONDO_SENDER_POSTAL_CODE=${SHIPMONDO_SENDER_POSTAL_CODE:-}
|
||||
- SHIPMONDO_SENDER_CITY=${SHIPMONDO_SENDER_CITY:-}
|
||||
- SHIPMONDO_SENDER_COUNTRY_CODE=${SHIPMONDO_SENDER_COUNTRY_CODE:-DK}
|
||||
- SHIPMONDO_SENDER_EMAIL=${SHIPMONDO_SENDER_EMAIL:-}
|
||||
- SHIPMONDO_SENDER_PHONE=${SHIPMONDO_SENDER_PHONE:-}
|
||||
- FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS}
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
|
||||
2
main.py
2
main.py
@ -135,6 +135,7 @@ from app.modules.orders.backend import router as orders_api
|
||||
from app.modules.orders.frontend import views as orders_views
|
||||
from app.modules.fedex.backend import router as fedex_api
|
||||
from app.modules.fedex.frontend import views as fedex_views
|
||||
from app.modules.shipmondo.backend import router as shipmondo_api
|
||||
from app.modules.also.backend import router as also_api
|
||||
from app.modules.manual.backend import router as manual_api
|
||||
from app.modules.manual.frontend import views as manual_views
|
||||
@ -493,6 +494,7 @@ app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||
app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
|
||||
app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"])
|
||||
app.include_router(also_api.router, prefix="/api/v1", tags=["ALSO Cloud Billing"])
|
||||
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
|
||||
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
|
||||
|
||||
74
migrations/1017_shipmondo_shipments.sql
Normal file
74
migrations/1017_shipmondo_shipments.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- Migration 1017: Shipmondo shipments linked to cases
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shipmondo_shipments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
booking_ref VARCHAR(64) NOT NULL UNIQUE,
|
||||
shipmondo_id VARCHAR(64),
|
||||
case_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
|
||||
product_code VARCHAR(80) NOT NULL,
|
||||
product_name VARCHAR(180),
|
||||
carrier_code VARCHAR(80),
|
||||
carrier_name VARCHAR(120),
|
||||
service_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
shipment_status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
own_agreement BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
automatic_select_service_point BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_name VARCHAR(150) NOT NULL,
|
||||
company_name VARCHAR(150),
|
||||
address_line1 VARCHAR(200) NOT NULL,
|
||||
address_line2 VARCHAR(200),
|
||||
postal_code VARCHAR(20) NOT NULL,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
country_code VARCHAR(2) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
email VARCHAR(150),
|
||||
tracking_number VARCHAR(128),
|
||||
tracking_url TEXT,
|
||||
total_amount NUMERIC(12,2),
|
||||
currency VARCHAR(8),
|
||||
dry_run BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
submitted_at TIMESTAMP,
|
||||
api_payload JSONB,
|
||||
api_response JSONB,
|
||||
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
CONSTRAINT shipmondo_shipments_status_check CHECK (
|
||||
shipment_status IN ('draft', 'submitted', 'booked', 'in_transit', 'delivered', 'cancelled', 'failed')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shipmondo_shipment_parcels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
shipment_id INTEGER NOT NULL REFERENCES shipmondo_shipments(id) ON DELETE CASCADE,
|
||||
weight_kg NUMERIC(10,3) NOT NULL,
|
||||
length_cm NUMERIC(10,2),
|
||||
width_cm NUMERIC(10,2),
|
||||
height_cm NUMERIC(10,2),
|
||||
description VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT shipmondo_parcel_weight_positive CHECK (weight_kg > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shipmondo_shipments_case_id ON shipmondo_shipments(case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shipmondo_shipments_status ON shipmondo_shipments(shipment_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_shipmondo_shipments_created_at ON shipmondo_shipments(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_shipmondo_parcels_shipment_id ON shipmondo_shipment_parcels(shipment_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_shipmondo_shipments_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS shipmondo_shipments_updated_at_trigger ON shipmondo_shipments;
|
||||
CREATE TRIGGER shipmondo_shipments_updated_at_trigger
|
||||
BEFORE UPDATE ON shipmondo_shipments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_shipmondo_shipments_updated_at();
|
||||
17
migrations/1018_economy_time_queue_settlement.sql
Normal file
17
migrations/1018_economy_time_queue_settlement.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Migration 1018: Preserve a trace from Economy Time Queue to the local order draft.
|
||||
-- A time entry must leave the queue when a local draft has been created, while
|
||||
-- still remaining available to the separate order/export workflow.
|
||||
|
||||
ALTER TABLE tmodule_times
|
||||
ADD COLUMN IF NOT EXISTS economy_order_draft_id INTEGER
|
||||
REFERENCES ordre_drafts(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS economy_settled_at TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS economy_settled_by INTEGER
|
||||
REFERENCES users(user_id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tmodule_times_economy_order_draft
|
||||
ON tmodule_times(economy_order_draft_id)
|
||||
WHERE economy_order_draft_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tmodule_times.economy_order_draft_id IS
|
||||
'Local ordrekladde created by Economy Time Queue. Prevents a duplicate draft for the same time.';
|
||||
@ -3,6 +3,7 @@
|
||||
let latestContextActions = { global: [], context: [] };
|
||||
let activeKey = 'timer';
|
||||
let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' };
|
||||
let locationsPanelState = { loading: false, loaded: false, rows: [], error: '', query: '' };
|
||||
let overviewFilter = null;
|
||||
let ws = null;
|
||||
let pollTimer = null;
|
||||
@ -1235,6 +1236,7 @@
|
||||
messages: 'Beskeder',
|
||||
tasks: 'Opgaver',
|
||||
notes: 'Noter',
|
||||
locations: 'Lokationer',
|
||||
boss: 'Sagsfordeling'
|
||||
};
|
||||
|
||||
@ -1244,6 +1246,7 @@
|
||||
messages: 'bi-chat-dots',
|
||||
tasks: 'bi-calendar-check',
|
||||
notes: 'bi-journal-text',
|
||||
locations: 'bi-geo-alt',
|
||||
boss: 'bi-person-workspace'
|
||||
};
|
||||
|
||||
@ -1253,6 +1256,7 @@
|
||||
messages: 'Interne samtaler samlet efter modtager med tydelig læst-status.',
|
||||
tasks: 'Prioritér næste handling ud fra deadlines og aktuelle påmindelser.',
|
||||
notes: 'Skriv hurtigt til venstre og genbrug dine noter fra arkivet til højre.',
|
||||
locations: 'Find og åbn aktive kundelokationer uden at forlade dit arbejde.',
|
||||
boss: 'Fordel supportkøen ud fra kapacitet, hast og ventetid.'
|
||||
};
|
||||
|
||||
@ -1291,6 +1295,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeKey === 'locations') {
|
||||
renderLocationsPanel(innerContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add specific headers/controls based on active tab
|
||||
if (activeKey === 'tasks') {
|
||||
const topBar = document.createElement('div');
|
||||
@ -1509,6 +1518,79 @@
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
function locationTypeLabel(value) {
|
||||
const labels = {
|
||||
kompleks: 'Kompleks', bygning: 'Bygning', etage: 'Etage',
|
||||
customer_site: 'Kundelokation', rum: 'Rum', kantine: 'Kantine',
|
||||
moedelokale: 'Mødelokale', vehicle: 'Køretøj'
|
||||
};
|
||||
return labels[String(value || '').toLowerCase()] || 'Lokation';
|
||||
}
|
||||
|
||||
async function loadLocationsPanel(force) {
|
||||
if (locationsPanelState.loading || (locationsPanelState.loaded && !force)) return;
|
||||
locationsPanelState.loading = true;
|
||||
locationsPanelState.error = '';
|
||||
try {
|
||||
const response = await fetch('/api/v1/locations?is_active=true&limit=100', { credentials: 'include' });
|
||||
if (!response.ok) throw new Error('Kunne ikke hente lokationer');
|
||||
const payload = await response.json();
|
||||
locationsPanelState.rows = Array.isArray(payload) ? payload : [];
|
||||
locationsPanelState.loaded = true;
|
||||
} catch (err) {
|
||||
locationsPanelState.error = err && err.message ? err.message : 'Kunne ikke hente lokationer';
|
||||
} finally {
|
||||
locationsPanelState.loading = false;
|
||||
if (activeKey === 'locations') renderTabPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function renderLocationsPanel(container) {
|
||||
const query = String(locationsPanelState.query || '').trim().toLowerCase();
|
||||
const rows = locationsPanelState.rows.filter(function (row) {
|
||||
if (!query) return true;
|
||||
return [row.name, row.customer_name, row.address_street, row.address_city, row.location_type]
|
||||
.some(function (value) { return String(value || '').toLowerCase().includes(query); });
|
||||
});
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'd-flex gap-2 align-items-center mb-3';
|
||||
toolbar.innerHTML = '<div class="input-group input-group-sm flex-grow-1">'
|
||||
+ '<span class="input-group-text"><i class="bi bi-search"></i></span>'
|
||||
+ '<input id="bbLocationsSearch" class="form-control" type="search" placeholder="Søg navn, kunde eller by" value="' + escapeHtml(locationsPanelState.query || '') + '">'
|
||||
+ '</div><button type="button" class="btn btn-sm btn-outline-secondary" id="bbLocationsRefresh" title="Opdater"><i class="bi bi-arrow-clockwise"></i></button>'
|
||||
+ '<a class="btn btn-sm btn-primary" href="/app/locations"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn alle</a>';
|
||||
container.appendChild(toolbar);
|
||||
|
||||
if (locationsPanelState.loading && !locationsPanelState.loaded) {
|
||||
container.insertAdjacentHTML('beforeend', '<div class="bb-panel-empty"><div><i class="bi bi-geo-alt"></i>Henter lokationer...</div></div>');
|
||||
} else if (locationsPanelState.error) {
|
||||
container.insertAdjacentHTML('beforeend', '<div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-1"></i>' + escapeHtml(locationsPanelState.error) + '</div>');
|
||||
} else if (!rows.length) {
|
||||
container.insertAdjacentHTML('beforeend', '<div class="bb-panel-empty"><div><i class="bi bi-geo-alt"></i><strong>Ingen lokationer fundet</strong><div class="small mt-1">Prøv en anden søgning eller åbn lokationsmodulet.</div></div></div>');
|
||||
} else {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'bb-location-list';
|
||||
rows.slice(0, 40).forEach(function (row) {
|
||||
const address = [row.address_street, [row.address_postal_code, row.address_city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||
const customer = row.customer_name ? '<span><i class="bi bi-building me-1"></i>' + escapeHtml(row.customer_name) + '</span>' : '';
|
||||
const addressHtml = address ? '<span><i class="bi bi-geo me-1"></i>' + escapeHtml(address) + '</span>' : '';
|
||||
const item = document.createElement('a');
|
||||
item.className = 'bb-location-card';
|
||||
item.href = '/app/locations/' + Number(row.id || 0);
|
||||
item.innerHTML = '<div class="min-w-0"><div class="fw-semibold text-truncate">' + escapeHtml(row.name || 'Unavngivet lokation') + '</div>'
|
||||
+ '<div class="small text-muted d-flex flex-wrap gap-2 mt-1"><span class="badge text-bg-light border">' + escapeHtml(locationTypeLabel(row.location_type)) + '</span>' + customer + addressHtml + '</div></div>'
|
||||
+ '<i class="bi bi-chevron-right text-muted"></i>';
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.appendChild(list);
|
||||
}
|
||||
const input = container.querySelector('#bbLocationsSearch');
|
||||
if (input) input.addEventListener('input', function () { locationsPanelState.query = input.value; renderTabPanel(); });
|
||||
const refresh = container.querySelector('#bbLocationsRefresh');
|
||||
if (refresh) refresh.addEventListener('click', function () { loadLocationsPanel(true); });
|
||||
if (!locationsPanelState.loaded && !locationsPanelState.loading) loadLocationsPanel();
|
||||
}
|
||||
|
||||
function ownTimerRows(payload) {
|
||||
const groups = normalizeSwitchableTimerPayload(payload);
|
||||
return groups.active.map(function (row) {
|
||||
|
||||
35
tests/test_economy_time_queue.py
Normal file
35
tests/test_economy_time_queue.py
Normal file
@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.economy.backend.router import _normalise_billing_method, _settlement_label
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_time_queue_normalises_legacy_settlement_names():
|
||||
assert _normalise_billing_method("prepaid_card") == "prepaid"
|
||||
assert _normalise_billing_method("fixed_price") == "subscription"
|
||||
assert _normalise_billing_method(None) == "invoice"
|
||||
assert _settlement_label("subscription") == "Abonnement / fast pris"
|
||||
|
||||
|
||||
def test_time_queue_has_preview_and_safe_draft_tracking():
|
||||
router_source = (ROOT / "app/economy/backend/router.py").read_text()
|
||||
template_source = (ROOT / "app/economy/frontend/time_queue.html").read_text()
|
||||
migration_source = (ROOT / "migrations/1018_economy_time_queue_settlement.sql").read_text()
|
||||
|
||||
assert '"/time-queue/preview-settlement"' in router_source
|
||||
assert '"/time-queue/settle"' in router_source
|
||||
assert "economy_order_draft_id" in router_source
|
||||
assert "preview-settlement" in template_source
|
||||
assert "Forhåndsvisning af afregning" in template_source
|
||||
assert "economy_order_draft_id" in migration_source
|
||||
|
||||
|
||||
def test_case_customer_is_authoritative_for_queue_and_settlement():
|
||||
router_source = (ROOT / "app/economy/backend/router.py").read_text()
|
||||
|
||||
# A moved/corrected case must not be invoiced to the historic customer
|
||||
# stored on a time entry.
|
||||
assert "COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id" in router_source
|
||||
assert "The case company is authoritative" in router_source
|
||||
95
tests/test_products_apigateway.py
Normal file
95
tests/test_products_apigateway.py
Normal file
@ -0,0 +1,95 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
products_router = importlib.import_module("app.products.backend.router")
|
||||
|
||||
|
||||
def test_normalize_apigw_product_accepts_nested_aliases():
|
||||
normalized = products_router._normalize_apigw_product(
|
||||
{
|
||||
"product": {
|
||||
"productName": "Managed switch",
|
||||
"part_number": "SW-24",
|
||||
"barcode": "570000000001",
|
||||
"net_price": 499.95,
|
||||
"legacy_category": "Netværk",
|
||||
"supplier": {"name": "Grossist", "code": "grossist"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert normalized["product_name"] == "Managed switch"
|
||||
assert normalized["sku"] == "SW-24"
|
||||
assert normalized["ean"] == "570000000001"
|
||||
assert normalized["price"] == 499.95
|
||||
assert normalized["category"] == "Netværk"
|
||||
assert normalized["supplier_name"] == "Grossist"
|
||||
assert normalized["supplier_code"] == "grossist"
|
||||
|
||||
|
||||
def test_normalize_apigw_product_builds_name_from_item_code():
|
||||
normalized = products_router._normalize_apigw_product(
|
||||
{"sku": "ABC-123", "supplier_name": "DCS"}
|
||||
)
|
||||
|
||||
assert normalized["product_name"] == "DCS · ABC-123"
|
||||
|
||||
|
||||
def test_normalize_apigw_product_does_not_unwrap_a_real_product_data_field():
|
||||
normalized = products_router._normalize_apigw_product(
|
||||
{"product_name": "Router", "sku": "RTR-1", "data": {"irrelevant": True}}
|
||||
)
|
||||
|
||||
assert normalized["product_name"] == "Router"
|
||||
assert normalized["sku"] == "RTR-1"
|
||||
|
||||
|
||||
def test_import_apigw_product_rejects_only_truly_unidentifiable_item():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
products_router._import_apigw_product_to_local({"product": {"id": 123}})
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "mangler både navn og varenummer" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_products_page_handles_gateway_errors_and_prevents_double_import():
|
||||
template = Path("app/products/frontend/list.html").read_text()
|
||||
|
||||
assert "const data = await res.json().catch(() => ({}));" in template
|
||||
assert "escapeHtml(e.message || 'Fejl ved søgning')" in template
|
||||
assert "async function importGatewayProduct(product, button = null)" in template
|
||||
assert "button.disabled = true;" in template
|
||||
assert "importGatewayProduct(product, button);" in template
|
||||
assert "Angiv mindst ét søgekriterium" in template
|
||||
assert "function safeGatewayUrl(value)" in template
|
||||
|
||||
|
||||
def test_gateway_catalog_search_is_read_only_until_explicit_import():
|
||||
template = Path("app/products/frontend/list.html").read_text()
|
||||
|
||||
assert "Søg hos alle grossister" in template
|
||||
assert "Søgningen opretter ikke varer i Hub'en" in template
|
||||
assert "function searchGatewayCatalogFromLocalQuery()" in template
|
||||
assert "openGatewaySearchModal(query, Boolean(query));" in template
|
||||
assert "params.append('per_page', '100');" in template
|
||||
assert "Importér til Hub" in template
|
||||
assert "auto_create: 'true'" not in template
|
||||
|
||||
|
||||
def test_gateway_catalog_results_show_supplier_and_product_details():
|
||||
template = Path("app/products/frontend/list.html").read_text()
|
||||
|
||||
assert "function renderGatewayProductCard(product, tokens)" in template
|
||||
assert "gatewayInfoItem('Grossist', supplier)" in template
|
||||
assert "gatewayInfoItem('Varenummer', product.sku)" in template
|
||||
assert "gatewayInfoItem('EAN / GTIN', product.ean)" in template
|
||||
assert "gatewayInfoItem('Producent', product.manufacturer)" in template
|
||||
assert "gatewayInfoItem('Kategori', product.category)" in template
|
||||
assert "gatewayInfoItem('Lager', stockParts.join(' · '))" in template
|
||||
assert "formatGatewayCurrency(product.price, product.currency)" in template
|
||||
assert "gateway-catalog-dialog" in template
|
||||
assert "gateway-search-panel" in template
|
||||
assert "gateway-results-grid" in template
|
||||
@ -197,6 +197,29 @@ def test_comment_actions_use_styled_accessible_chips():
|
||||
assert 'for="commentActionAwaitCustomer"' in template
|
||||
|
||||
|
||||
def test_case_history_shows_actor_timestamp_and_all_case_activity_filters():
|
||||
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
||||
|
||||
assert "<strong>Hvornår:</strong>" in template
|
||||
assert "<strong>Hvem:</strong>" in template
|
||||
assert 'id="historyFilterCaseActivity"' in template
|
||||
assert "['case', 'tag', 'todo', 'relation']" in template
|
||||
|
||||
|
||||
def test_case_timeline_uses_real_actors_and_action_timestamps():
|
||||
source = Path("app/modules/sag/backend/router.py").read_text()
|
||||
|
||||
assert '"title": "Sag oprettet"' in source
|
||||
assert 'r.created_at' in source
|
||||
assert 'employee.user_id = t.medarbejder_id' in source
|
||||
assert 'LOWER(recorder.username) = LOWER(t.user_name)' in source
|
||||
assert 'Registreret for: {employee_name}' in source
|
||||
assert '"forfatter": str(row.get("actor_name") or "System")' in source
|
||||
assert '"event_type": "tag"' in source
|
||||
assert '"event_type": "todo"' in source
|
||||
assert '"event_type": "relation"' in source
|
||||
|
||||
|
||||
def test_safe_case_html_renders_formatting_and_drops_executable_code():
|
||||
result = sanitize_safe_html(
|
||||
'<style>body{display:none}</style>'
|
||||
|
||||
113
tests/test_shipmondo_integration.py
Normal file
113
tests/test_shipmondo_integration.py
Normal file
@ -0,0 +1,113 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.modules.shipmondo.backend.service import (
|
||||
build_shipmondo_payload,
|
||||
extract_label_base64,
|
||||
extract_price,
|
||||
extract_tracking_number,
|
||||
)
|
||||
from app.modules.shipmondo.models.schemas import ShipmondoBookingCreate
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_shipmondo_schema_normalizes_country_and_services():
|
||||
payload = ShipmondoBookingCreate.model_validate(
|
||||
{
|
||||
"case_id": 88,
|
||||
"product_code": "GLSDK_HD",
|
||||
"service_codes": ["email_nt", "EMAIL_NT", "sms_nt"],
|
||||
"address": {
|
||||
"recipient_name": "Lene Jensen",
|
||||
"address_line1": "Vindegade 112",
|
||||
"postal_code": "5000",
|
||||
"city": "Odense C",
|
||||
"country_code": "dk",
|
||||
},
|
||||
"parcels": [{"weight_kg": 2, "description": "Router"}],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.address.country_code == "DK"
|
||||
assert payload.service_codes == ["EMAIL_NT", "SMS_NT"]
|
||||
|
||||
|
||||
def test_build_shipmondo_payload_uses_grams_and_required_parties(monkeypatch):
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_NAME", "BMC Networks")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_ADDRESS1", "Testvej 1")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_POSTAL_CODE", "4600")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_CITY", "Køge")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_COUNTRY_CODE", "DK")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_ATTENTION", "")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_ADDRESS2", "")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_EMAIL", "fragt@example.test")
|
||||
monkeypatch.setattr(settings, "SHIPMONDO_SENDER_PHONE", "70112233")
|
||||
|
||||
result = build_shipmondo_payload(
|
||||
{
|
||||
"booking_ref": "SMD-20260819-ABC12345",
|
||||
"case_id": 88,
|
||||
"product_code": "GLSDK_HD",
|
||||
"service_codes": ["EMAIL_NT", "SMS_NT"],
|
||||
"own_agreement": False,
|
||||
"automatic_select_service_point": True,
|
||||
"recipient_name": "Lene Jensen",
|
||||
"company_name": "Kunde ApS",
|
||||
"address_line1": "Vindegade 112",
|
||||
"address_line2": None,
|
||||
"postal_code": "5000",
|
||||
"city": "Odense C",
|
||||
"country_code": "DK",
|
||||
"email": "lene@example.test",
|
||||
"phone": "50607080",
|
||||
"parcels": [
|
||||
{
|
||||
"weight_kg": 2.25,
|
||||
"length_cm": 20,
|
||||
"width_cm": 30,
|
||||
"height_cm": 40,
|
||||
"description": "Router",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert result["product_code"] == "GLSDK_HD"
|
||||
assert result["service_codes"] == "EMAIL_NT,SMS_NT"
|
||||
assert result["automatic_select_service_point"] is True
|
||||
assert result["label_format"] == "10x19_pdf"
|
||||
assert result["parties"][0]["type"] == "sender"
|
||||
assert result["parties"][1]["type"] == "receiver"
|
||||
assert result["parties"][1]["attention"] == "Lene Jensen"
|
||||
assert result["parcels"][0]["weight"] == 2250
|
||||
assert result["parcels"][0]["internal_reference"] == "SMD-20260819-ABC12345-1"
|
||||
|
||||
|
||||
def test_shipmondo_response_extractors_cover_real_response_shape():
|
||||
response = {
|
||||
"id": 123456,
|
||||
"price": "50.00",
|
||||
"parcels": [{"pkg_no": "012345678910", "label_base64": "UERG"}],
|
||||
}
|
||||
|
||||
assert extract_tracking_number(response) == "012345678910"
|
||||
assert extract_price(response) == (50.0, "DKK")
|
||||
assert extract_label_base64(response) == "UERG"
|
||||
|
||||
|
||||
def test_case_shipping_ui_supports_both_providers():
|
||||
template = (ROOT / "app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
|
||||
|
||||
assert 'value="fedex"' in template
|
||||
assert 'value="shipmondo"' in template
|
||||
assert "/api/v1/shipmondo/products" in template
|
||||
assert "shippingSelectedBooking.provider" in template
|
||||
assert "Promise.allSettled" in template
|
||||
|
||||
|
||||
def test_shipmondo_configuration_is_safe_by_default():
|
||||
assert settings.SHIPMONDO_API_BASE_URL.endswith("/api/public/v3")
|
||||
assert isinstance(settings.SHIPMONDO_READ_ONLY, bool)
|
||||
assert isinstance(settings.SHIPMONDO_DRY_RUN, bool)
|
||||
Loading…
Reference in New Issue
Block a user