Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.
+ Varer og e-conomic
diff --git a/app/services/economic_service.py b/app/services/economic_service.py
index bc524c1..6174d1f 100644
--- a/app/services/economic_service.py
+++ b/app/services/economic_service.py
@@ -287,10 +287,9 @@ class EconomicService:
Returns:
True if successful, False otherwise
"""
- if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
- logger.warning(f"⚠️ e-conomic update blocked by safety flags (READ_ONLY={settings.ECONOMIC_READ_ONLY}, DRY_RUN={settings.ECONOMIC_DRY_RUN})")
- logger.info(f"Would update customer {customer_number} with: {update_data}")
- return False
+ # Customer creation is allowed, updates are deliberately not.
+ logger.warning('e-conomic customer update blocked by the Hub write policy')
+ return False
try:
async with aiohttp.ClientSession() as session:
@@ -379,8 +378,8 @@ class EconomicService:
Returns:
Created supplier data with supplierNumber or None if failed
"""
- if not self._check_write_permission("create_supplier"):
- return None
+ logger.warning('e-conomic supplier creation blocked by the Hub write policy')
+ return None
try:
# Build supplier payload
@@ -824,8 +823,8 @@ class EconomicService:
Dict with voucher details or error info
"""
# 🚨 SAFETY CHECK
- if not self._check_write_permission("create_journal_supplier_invoice"):
- return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
+ logger.warning('e-conomic voucher posting blocked by the Hub write policy')
+ return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
try:
# Extract year from invoice date for accounting year
@@ -1052,8 +1051,8 @@ class EconomicService:
Dict with success status
"""
# 🚨 SAFETY CHECK
- if not self._check_write_permission("upload_voucher_attachment"):
- return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
+ logger.warning('e-conomic voucher attachment blocked by the Hub write policy')
+ return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
try:
# Read PDF file
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 51e8624..faf2fdc 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -162,6 +162,9 @@
Integrationer
+
+ Varer og e-conomic
+
Telefoni
@@ -263,6 +266,7 @@
diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py
index f1f03f4..39e5de5 100644
--- a/app/timetracking/backend/economic_export.py
+++ b/app/timetracking/backend/economic_export.py
@@ -19,6 +19,7 @@ from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_update, execute_query_single
+from app.core.economic_write_policy import assert_economic_write_allowed
from app.timetracking.backend.models import (
TModuleEconomicExportRequest,
TModuleEconomicExportResult
@@ -195,6 +196,43 @@ class EconomicExportService:
status_code=400,
detail="Order has no lines"
)
+
+ from app.products.backend.economic_documents import active_connection, export_document
+ connection = active_connection()
+ if connection:
+ from app.core.auth_service import AuthService
+ if not user_id or not AuthService.user_has_permission(user_id, 'economic.documents.export'):
+ raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
+ if self.read_only or self.dry_run or settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
+ return TModuleEconomicExportResult(success=True, dry_run=True, order_id=request.order_id,
+ message='Safety mode: ingen ekstern skrivning', details={})
+ customer = execute_query_single('SELECT hub_customer_id FROM tmodule_customers WHERE id=%s', (order['customer_id'],))
+ if not customer or not customer['hub_customer_id']:
+ raise HTTPException(409, 'Tidskundens Hub-kobling mangler')
+ time_ids = sorted({time_id for line in lines for time_id in (line.get('time_entry_ids') or [])})
+ if time_ids:
+ other = execute_query_single('''SELECT o.id FROM tmodule_orders o JOIN tmodule_order_lines l ON l.order_id=o.id
+ WHERE o.id<>%s AND o.economic_draft_id IS NOT NULL AND l.time_entry_ids && %s::integer[] LIMIT 1''', (request.order_id, time_ids))
+ if other:
+ raise HTTPException(409, f"Tiden findes allerede på eksporteret ordre {other['id']}")
+ converted = []
+ for line in lines:
+ number = line.get('product_number') or settings.TIMETRACKING_ECONOMIC_PRODUCT
+ product = execute_query_single('SELECT id,economic_unit_number FROM products WHERE economic_connection_id=%s AND economic_product_number=%s', (connection['id'], str(number)))
+ if not connection.get('time_unit_number') or not product or product['economic_unit_number'] != connection['time_unit_number']:
+ raise HTTPException(409, 'Tidsproduktet skal importeres/kobles og have den aftalte timeenhed før eksport')
+ converted.append({**line, 'product_id': product['id'], 'currency':'DKK'})
+ exported = await export_document(connection, 'order', f'tmodule-order:{request.order_id}',
+ customer['hub_customer_id'], converted, notes=order.get('notes'), user_id=user_id,
+ module='timetracking')
+ number = exported['economic_order_number']
+ execute_update('UPDATE tmodule_orders SET economic_draft_id=%s,economic_order_number=%s,exported_at=now(),exported_by=%s WHERE id=%s', (int(number), str(number), user_id, request.order_id))
+ # An exported draft is not a booked invoice: do not mark time as billed here.
+ audit.log_export_completed(order_id=request.order_id, economic_draft_id=int(number),
+ economic_order_number=str(number), dry_run=False, user_id=user_id)
+ return TModuleEconomicExportResult(success=True, dry_run=False, order_id=request.order_id,
+ economic_draft_id=int(number), economic_order_number=str(number),
+ message='Tidsordre eksporteret som kladde; ikke bogført', details={'export_id':exported['export_id']})
# Log export start
audit.log_export_started(
@@ -437,6 +475,7 @@ class EconomicExportService:
# Call e-conomic API
async with aiohttp.ClientSession() as session:
+ assert_economic_write_allowed("POST", "/orders/drafts")
async with session.post(
f"{self.api_url}/orders/drafts",
headers=self._get_headers(),
diff --git a/docs/ECONOMIC_PRODUCT_CATALOG.md b/docs/ECONOMIC_PRODUCT_CATALOG.md
new file mode 100644
index 0000000..f56965c
--- /dev/null
+++ b/docs/ECONOMIC_PRODUCT_CATALOG.md
@@ -0,0 +1,24 @@
+# Varer og e-conomic
+
+## Første, sikre aktivering
+
+1. Åbn **Indstillinger → Varer, priser og synkronisering**.
+2. Vælg **Tilknyt aftale**. Hub læser først aftalen og udfylder aftalenummer, firma og basisvaluta; bekræft kun hvis de er rigtige.
+3. Vælg **Kontrollér import**. Det henter varegrupper, enheder og alle varer, men ændrer ingen Hub-produkter.
+4. Gennemgå previewet under **Sync og fejl**. Importér derefter alle varer. Spærrede varer importeres også, men kan ikke vælges til ny eksport.
+5. Under **Varegrupper** vælges de grupper, som nye varer må oprettes i. Vælg også den verificerede timeenhed under **Automatisk synkronisering**, hvis tidsordrer skal eksporteres.
+6. Knyt eksisterende Hub-produkter eller opret en enkelt valgt vare. Koblingen er låst. En administrativ omkobling kræver gammelt varenummer, årsag og audit.
+7. Først når afklaringslisten er tom for de varer, der skal faktureres, aktiveres forbindelsen. Det slår den strenge varekontrol til på eksport.
+
+## Daglig brug
+
+- Hub-pris og e-conomic-referencepris er to forskellige felter. En prisændring i Hub ændrer ikke e-conomic-referenceprisen eller gamle kladder.
+- **Kontrollér nye priser** på en gemt ordre viser før/efter og ændrer kun kladden efter en eksplicit bekræftelse. Abonnementspriser bevares.
+- Eksport opretter kun en kladde. Den bogfører eller sender ikke en faktura.
+- En timeout bliver vist som **Uafklaret**. Brug **Afstem**. Send aldrig dokumentet igen, før afstemningen har fundet præcis én kladde med Hub-referencen.
+
+## Safety mode
+
+Selv når read-only og dry-run er slået fra, er direkte skrivning låst til tre handlinger: opret kunde, opret vare og opret ordrekladde. Ændring af en eksisterende kunde eller vare kræver en lokal anmodning og godkendelse fra en anden bruger med særskilt rettighed. Opretteren kan ikke godkende selv, og e-conomic-data genkontrolleres før udførelse. Sletning, fakturakladder, leverandører, bilag og bogføring kan ikke udføres fra Hub. Statuslinjen viser både den globale tilstand og eventuelle moduloverrides for ordre/tid.
+
+Produktintegrationens migrationer er `242_economic_product_integration.sql` og `243_economic_four_eyes_changes.sql`. De er additive og genkørbare; de gætter ikke eksisterende `sku_internal` eller `er_number` som e-conomic-varenumre.
diff --git a/fremtidige planer/README.md b/fremtidige planer/README.md
index f57d6f2..f72e205 100644
--- a/fremtidige planer/README.md
+++ b/fremtidige planer/README.md
@@ -4,6 +4,7 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
## Aktive planer
+- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
diff --git a/fremtidige planer/vareintegration-economic.md b/fremtidige planer/vareintegration-economic.md
new file mode 100644
index 0000000..60e5db1
--- /dev/null
+++ b/fremtidige planer/vareintegration-economic.md
@@ -0,0 +1,210 @@
+# Plan – vareintegration mellem BMC Hub og e-conomic
+
+Status: Implementeret lokalt 12. september 2026. Produktionsaktivering kræver separat, kontrolleret import-preview og gennemgang af e-conomic-forbindelsen.
+Opdateret: 12. september 2026.
+
+Tillæg 13. september 2026: Ændring af eksisterende kunder og varer er kun tilladt gennem 4-øjne-flowet. Opretteren kan ikke godkende sin egen anmodning; før/efter-data genkontrolleres mod e-conomic umiddelbart før en godkendt `PUT`. Alle andre opdateringer og sletninger forbliver blokeret.
+
+## 1. Formål og leverance
+
+Hub er arbejdsfladen for produkter, leverandørdata og priser til tilbud, ordrer, abonnementer og tidsregistrering. e-conomic er autoritativ for økonomiske varereferencer, varegrupper, moms og bogførte dokumenter. Operatøren skal kunne importere, oprette, finde og vedligeholde varer fra Hub uden dobbeltindtastning.
+
+Planen erstatter antagelsen om, at et lokalt SKU automatisk er et e-conomic-varenummer. Alle økonomiske eksportveje skal bruge en verificeret kobling. Eksisterende dokumenter og produktrelationer bevares under indførelsen.
+
+## 2. Beslutninger
+
+- Bevar eksisterende `products.id` som integer og eksisterende `uuid`. Der etableres ikke et parallelt produktkatalog.
+- Tilføj `economic_product_number` som tekst på højst 25 tegn. Nummeret er permanent efter kobling og kan indeholde bogstaver og foranstillede nuller.
+- e-conomic ejer det officielle nummer. Det betyder ikke, at API'et automatisk tildeler det: ved produktoprettelse sender integrationen et nummer efter en kontrolleret nummerpolitik.
+- Lokale kladder er tilladt. Økonomisk eksport kræver verificeret produktkobling.
+- Alle e-conomic-produkter importeres, også spærrede/inaktive. Manglende poster i en delvis import må aldrig udløse deaktivering.
+- Navn vedligeholdes i Hub efter første import. Ændringer gemmes kun lokalt og må aldrig sendes som en opdatering til e-conomic.
+- Økonomisk varegruppe og ekstern status vedligeholdes i e-conomic i version 1. Redigering af disse fra Hub er en senere udvidelse med særskilte rettigheder.
+- Varegruppe og enhed arves ikke blindt fra produkt `1000`. De vælges fra synkroniserede værdier eller en eksplicit administrativ mapping.
+- Standardpriser er separate: `sales_price` er Hub-standardpris; `economic_sales_price_reference` er e-conomics referencepris.
+- Ukendte katalogvarer konverteres ikke automatisk til fritekst ved eksport.
+- Eksisterende katalogvarer i e-conomic overskrives ikke som følge af en ordreeksport.
+
+## 3. Ejerskab og konflikter
+
+| Felt | Autoritet | Regel |
+| --- | --- | --- |
+| Officielt varenummer | e-conomic | Permanent kobling, ingen almindelig redigering |
+| Navn | Hub efter første import | Lokal ændring forbliver i Hub; ekstern afvigelse kan accepteres lokalt |
+| Økonomisk varegruppe, moms og bogføring | e-conomic | Importeres; Hub-kategorier må ikke ændre dem indirekte |
+| Ekstern aktiv/spærret-status | e-conomic | Blokerer ny eksport, historik bevares |
+| Intern kategori, udfasning, metadata | Hub | Import overskriver ikke lokale værdier |
+| Referencepris | e-conomic | Opdaterer kun referencefeltet |
+| Hub-standardpris og prisregler | Hub | Ændrer ikke allerede gemte dokumentpriser |
+| Leverandørpris og lagerstatus | Leverandør | Gemmes pr. leverandør med kilde og tidspunkt |
+| Bogført faktura | e-conomic | Hub spejler status og reference |
+
+Sammenlign lokal værdi, senest synkroniserede værdi og ny ekstern værdi ved import. Gem feltversion og hash, så en forsinket import ikke overskriver en nyere lokal ændring. En ekstern navneændring efter første import kan accepteres ind i Hub. Hub må ikke sende navneændringer tilbage til e-conomic.
+
+## 4. Datamodel og kompatibilitet
+
+### Produkter
+
+Udvid eksisterende tabel og genbrug eksisterende felter, hvor betydningen passer:
+
+- `id`, `uuid`, navn, korte/lange beskrivelser, producent og producent-SKU.
+- `economic_connection_id`, `economic_product_number`, `economic_product_group_number`.
+- `economic_sales_price_reference`, separat referencevaluta, ekstern enhedsreference.
+- Eksisterende `sales_price`, `cost_price`, samt eksplicit valuta og kilde for disse beløb.
+- Intern kategori og underkategori; eksisterende metadata og tekniske JSON-felter.
+- `is_active_in_economic`, intern aktiv-status og separat `lifecycle_status`: kladde, aktiv, udfases, inaktiv.
+- `sync_status`: ikke koblet, afventer, synkroniserer, synkroniseret, konflikt, fejl.
+- Seneste eksterne snapshot/hash, lokal versionsværdi, tidspunkt for import og vellykket afsendelse. Eksternt ændringstidspunkt gemmes kun, hvis API'et faktisk leverer det.
+
+Den eksterne kobling er unik inden for en e-conomic-forbindelse/aftale. Indekset omfatter også soft-deaktiverede produkter, så nummeret ikke genbruges ved en fejl. En ændring af aftalens credentials må ikke lydløst koble eksisterende produkter til en anden aftale.
+
+`er_number` undersøges før migration. Kun værdier, der kan verificeres mod den rigtige aftale, overføres. `sku_internal`, EAN og navne er kandidater til manuel afklaring, aldrig automatisk bevis. Bevar bagudkompatibel læsning af gamle felter under overgangen og stop divergerende dobbeltredigering.
+
+### Leverandører og kategorier
+
+Ét Hub-produkt kan have flere leverandørtilbud. Brug en relation med produkt, leverandør, leverandør-SKU, kost, valuta, gyldighed, lagerstatus og seneste opdatering. Eksisterende enkeltleverandørfelter bevares som kompatibel visning af primær leverandør.
+
+Microsoft- og distributøridentiteter skal kunne skelne mellem produkt, variant, licensperiode, betalingsinterval og marked. Samme varenavn er ikke tilstrækkeligt til at slå tilbud sammen.
+
+Genbrug Hub-kategorier og udvid med hierarki efter behov. Varegrupper caches separat pr. e-conomic-forbindelse med nummer, navn, økonomiske referencer og Hub-mapping. Interne flag som »må bruges til nye varer« og »udfases« holdes adskilt fra API-felter; antag ikke, at e-conomic har et aktiv-flag på alle gruppetyper.
+
+### Integration og audit
+
+Gem synkroniseringsjobs, dokumenteksportforsøg og audit separat fra produkter. Hvert job har mål, operation, lokal version, integrationsnøgle, forsøg, næste forsøg, låseudløb, resultat og korrelations-id. Genbrug eksisterende audit- og jobkomponenter efter en konkret gennemgang.
+
+Varenummerlåsen håndhæves i backend og database. Særskilt administrativ omkobling kræver årsag, verificeret mål og audit; den omskriver aldrig historiske dokumentlinjer eller forsøger automatisk at omnummerere varen i e-conomic.
+
+## 5. Produktimport og oprettelse
+
+### Første import
+
+1. Kontrollér den tilsluttede aftale og hent varegrupper samt enheder.
+2. Hent alle produktpages uden et filter, der skjuler spærrede varer.
+3. Vis import-preview med nye, opdaterede, uændrede og konfliktfyldte poster.
+4. Match på verificeret ekstern kobling. Eksisterende ukoblede Hub-varer behandles i en afklaringsliste.
+5. Gem checkpoint pr. page og afsluttende rapport. Genkørsel skal give samme resultat.
+
+### Løbende sync
+
+Standardinterval er 15 minutter, administrativt konfigurerbart. Brug ændringsmarkør, hvis API'et understøtter en egnet markør; ellers pagineret sammenligning med hashes. Fuld gensync kan startes manuelt og deler samme lås som almindelig import. Et fuldt gennemløb skal være afsluttet, før fravær af en tidligere vare behandles som mulig ekstern sletning.
+
+### Opret fra Hub
+
+Operatøren vælger »Opret i e-conomic« på produktet eller i eksportens kontrolvindue. Navn, varegruppe, enhed og nummerforslag vises samlet. En administrativt valgt nummersekvens reserveres lokalt med unik nøgle; ved konflikt med e-conomic reserveres et nyt nummer før ny oprettelse. Brug ikke `max(varenummer)+1` uden samtidighedskontrol.
+
+API-kontrakten kræver et eksplicit produktnummer. Ved succes verificeres nummeret i svaret og koblingen gemmes. Ved timeout bliver oprettelsen »Uafklaret«; slå det reserverede nummer op og sammenlign forventede data, før der forsøges igen. Et eksisterende nummer med andre data er en konflikt, ikke en tilladelse til at overtage varen.
+
+Kun de valgte varer oprettes; import af en hel leverandørs katalog må ikke automatisk oprette alle varer i e-conomic. Automatisk oprettelse kan senere aktiveres for godkendte kategorier med komplet mapping. Version 1 bruger kontrolleret oprettelse i samme arbejdsflow.
+
+## 6. Gemning og baggrundsjob
+
+Gem produktændring og audit i samme database-transaktion. Lokale navne- og metadataændringer får status `local_only` og opretter ikke et eksternt skrivejob. Outbox bruges kun til de tre tilladte oprettelser: kunder, varer og ordrekladder.
+
+Oprettelsesjobs behandles versionssikkert; et forældet job må ikke ændre en nyere lokal kobling. Eksisterende e-conomic-objekter opdateres aldrig fra Hub.
+
+Retry gælder midlertidige fejl med eksponentiel ventetid, jitter og respekt for `Retry-After`. Standard er højst fem automatiske forsøg. Valideringsfejl kræver rettelse; credentialfejl pauser forbindelsens skrivninger. Uafklarede oprettelser genafstemmes før retry. Worker-låse har udløb, så jobs kan genoptages efter nedbrud.
+
+## 7. Prisberegning og historik
+
+Prioriteten er:
+
+1. Eksplicit, rettighedsgodkendt manuel linjepris.
+2. Aftalt kundespecifik produktpris.
+3. Kundens prisregel.
+4. Produktregel, derefter nærmeste kategoriregel.
+5. Hub-standardpris.
+6. e-conomic-referencepris, hvis valuta og enhed er kompatible.
+7. Blokering og krav om pris, hvis ingen gyldig pris findes.
+
+Inden for samme niveau bruges regelprioritet og specificitet. Identiske prioriteringer med overlappende gyldighed afvises eller vises som konflikt; databaserækkefølge afgør aldrig prisen. Version 1 understøtter fast pris, procentvis rabat og tillæg på kost. Dækningsgrad og tillæg på kost er forskellige beregninger og skal navngives særskilt.
+
+Brug Decimal/NUMERIC, dokumenteret afrunding og beløb eksklusive moms. Nulpris er en gyldig eksplicit værdi, ikke et manglende beløb. Valutakonvertering kræver kurs, kilde og dato; samme tal må aldrig blot ommærkes med en anden valuta. Negative priser, kreditnotaer og negative mængder skal have egne dokumentregler.
+
+Gem snapshot på eksisterende dokumentlinjer: produkt-id, ekstern kobling, navn, beskrivelse, mængde, enhed, kost hvor relevant, pris, rabat, momsgrundlag/moms, valuta, total, prisregelversion, beregningsforklaring og tidspunkt. Genbrug de eksisterende linjetabeller frem for en parallel dokumentmodel.
+
+Gemte kladder genberegnes kun ved en eksplicit handling med før/efter-visning. Bogførte dokumenter er uforanderlige. Tilbud-til-ordre-overførsel bevarer den aftalte pris. Abonnementer skelner mellem fast aftalepris og pris beregnet ved næste periode; hver faktureret periode får sit eget snapshot. En produktprisændring regulerer ikke automatisk alle eksisterende aftaler.
+
+## 8. Eksport af ordrer
+
+Version 1 understøtter kun oprettelse af ordrekladder. Hub må ikke oprette eller ændre fakturakladder, bogføre, sende eller slette dokumenter. En ordrekladde i e-conomic er en rigtig ekstern skrivning og er ikke det samme som dry-run.
+
+Preflight kontrollerer debitor, produktkoblinger, ekstern status, varegruppe, enhed, dokumentvaluta, linjepris, rabat, momszone, betalingsbetingelser, layout og tidligere eksport. Kundens eksterne standarder bruges som udgangspunkt. Et eksplicit layoutvalg skal valideres og respekteres eller afvises tydeligt.
+
+Eksporten sender det verificerede varenummer og linjens snapshotpris som `unitNetPrice`. Der skiftes aldrig lydløst til e-conomics standardpris eller en anden valuta. Produktreference og pris holdes adskilt.
+
+Manglende produktkobling åbner »Knyt eksisterende vare«, »Opret i e-conomic« eller »Ret senere«. Manuelle prisbærende linjer kræver ligeledes en valgt gyldig økonomisk vare. Rene overskrifter/kommentarer kan undtages, hvis dokument-API'et understøtter dem. Fritekstfallback fra v2.8.6 udfases ved aktivering af dette flow.
+
+### Dubletter og uafklarede svar
+
+Tilstande: afventer → sender → oprettet → verificeret. Fejl deles i afvist og uafklaret. Gem integrationsnøgle før afsendelse og brug lås pr. dokumentrevision. Gem returneret dokumentnummer straks og atomisk med lokal eksportstatus.
+
+En lokal nøgle er ikke i sig selv ekstern idempotens. Ved timeout eller mistet svar søges via en stabil Hub-reference i et understøttet e-conomic-referencefelt, før nyt POST tillades. Beløb/kunde/dato er kun supplerende kontrol, ikke sikkert match alene. Nul eller flere tvetydige resultater kræver manuel afklaring. Hvis den aktuelle API-kontrakt tilbyder egentlig idempotens, anvendes den også.
+
+Gentagne klik og samtidige workers må ikke sende samme revision to gange. Et eksternt dokument oprettet før et lokalt nedbrud skal kunne genfindes. Vareoprettelse og dokumentoprettelse har separate resultater: en korrekt oprettet vare genbruges, hvis den efterfølgende ordre fejler.
+
+## 9. Brugerflader og rettigheder
+
+Produktoversigten er en kompakt tabel med varenummer, navn, økonomisk gruppe, intern kategori, Hub-pris, referencepris, status og sync-status. Inaktive/udfasende produkter skjules normalt ved nyvalg og findes via et filter. Detaljesiden viser låst ekstern reference, navn, metadata, leverandører, priser og historik.
+
+Settings → e-conomic samler Forbindelse, Produktsync, Varegrupper, Eksport og Fejl/log. Vis aftaleidentitet uden credentials, sidste/næste kørsel, import-preview, fuld sync, mapping og læsbare fejl med korrelations-id. »Markér som behandlet« ændrer aldrig faktisk sync-/eksportstatus.
+
+Rettigheder skelner mellem læsning, kost/DB, produktredigering, eksterne navneændringer, produktoprettelse, prisregler, manuel pris og eksport/integrationsadministration. De håndhæves både i UI og API. Kostfelter udelades server-side for brugere uden adgang.
+
+Read-only og dry-run kontrolleres ved hver ekstern skrivning, også i workers og produktjobs. Konfigurationen viser effektiv tilstand og eventuelle moduloverrides. Dry-run udfører ingen produktoprettelse, navneopdatering eller dokumentoprettelse. Miljø og aftale er entydigt adskilt mellem test og produktion.
+
+## 10. API og driftskrav
+
+Udvid eksisterende `/api/v1/products` og eksisterende dokumentruter bagudkompatibelt. Nye operationer omfatter produktkobling, ekstern oprettelse, sync-status, import-preview/kørsel, varegruppemapping, prisberegning, eksportvalidering og afklaring af uafklarede eksportforsøg. Langvarige handlinger returnerer job-id og en statusressource.
+
+Mål antal afventende/fejlede jobs, ældste ventetid, konflikter, seneste komplette sync og uafklarede oprettelser. Log kun nødvendige feltændringer og fejl; tokens, headers og komplette følsomme payloads må ikke logges. Audit registrerer bruger/systemaktør, før/efter, årsag, objekt, tidspunkt og korrelations-id. Adgang og retention fastlægges sammen med eksisterende auditpraksis.
+
+## 11. Leveringsrækkefølge
+
+### Fase 0 – datakortlægning
+
+Kortlæg reelle tabeller, migrationshistorik, alle eksportveje, produktreferencer, prisberegning, jobs og audit. Undersøg `er_number` og dubletter. Kontrollér API-kontrakter og aftaleidentitet med read-only kald. Lever mappingrapport, import-preview og migrationsplan. Produktionsdata ændres ikke som led i kortlægningen.
+
+### Fase 1 – fundament og import
+
+Additive migrationer, ekstern kobling, grupper/enheder, read-only import og produktvisning. Bevar eksisterende integerrelationer. Importér i batches og verificér antal samt konflikter. Aktivering af streng eksportkontrol afventer, at relevante eksisterende produkter er koblet eller står i en synlig afklaringsliste.
+
+### Fase 2 – varer fra Hub og korrekt ordreeksport
+
+Kontrolleret produktoprettelse, nummerreservation, koblingslås, preflight og brug af `economic_product_number` på ordreeksport. Fjern SKU-antagelsen og lydløs fritekstfallback. Lever dette før den udvidede prismotor, så den aktuelle eksportfejl bliver løst tidligt.
+
+### Fase 3 – lokal navneadministration
+
+Versionskontrol, konflikter, manuel lokal afklaring og varegruppemapping. Der aktiveres ingen ekstern navnesync.
+
+### Fase 4 – priser og ordrekladder
+
+Fælles prisberegning, snapshots, kunderegler, abonnementspolitik og kostrettigheder. Eksportjournal og reconciliation gælder kun ordrekladder.
+
+### Fase 5 – drift og overgang
+
+Overvågning, driftsvejledning, valideret fuld genkørsel og endelig udfasning af gamle referencefelters skriveadgang. Migrerede data beholdes ved applikationsrollback; dokumenter og eksterne varer slettes ikke automatisk som rollback.
+
+## 12. Acceptkriterier og tests
+
+- Førstesync og fuld gensync kan genkøres og genoptages uden dubletter; inaktive varer bevares.
+- Afbrudt paginering deaktiverer ikke varer og præsenteres ikke som gennemført import.
+- Foranstillede nuller/alfanumeriske numre bevares; samme nummer i en anden aftale blandes ikke sammen.
+- En lokal navneændring bliver i Hub og opretter ingen ekstern skrivning; eksterne samtidige ændringer testes ved import.
+- Nummerkollision, dobbeltklik og timeout efter vellykket produktoprettelse giver ingen ukontrolleret genoprettelse.
+- Ingen almindelig produktopdatering kan ændre ekstern kobling; administrativ omkobling bevarer snapshots.
+- Hub-linjepris, nulpris, valuta, rabat, afrunding og prisregelkonflikt testes med konkrete forventede beløb.
+- Kundeaftaler og allerede fakturerede abonnementsperioder ændres ikke ved nye produktpriser.
+- Eksport med manglende, spærret eller ugyldigt koblet produkt blokeres med en brugbar rettehandling.
+- Manglende layout, betalingsbetingelser og momszone afvises inden dokumentoprettelse, hvor oplysningerne kan valideres.
+- To samtidige eksportforsøg og timeout efter ekstern succes testes; uafklaret status kræver reconciliation før retry.
+- Kun `POST customers`, `POST products` og `POST orders/drafts` kan passere den centrale allowlist; alle andre eksterne writes er blokeret. Read-only/dry-run blokerer også de tre tilladte oprettelser.
+- Kostrettigheder kontrolleres ved direkte API-adgang, og logs testes for credentiallæk.
+- End-to-end-test gennemføres på separat testaftale. Produktion testes med read-only preview før kontrolleret aktivering.
+
+## 13. Dokumentationsgrundlag
+
+Oprindelig brugerplan: »Plan – vareintegration mellem BMC Hub og e-conomic«, indsendt 12. september 2026. Denne plan samler den med de besluttede tilpasninger til eksisterende Hub.
+
+Kodegrundlag: `migrations/106_products.sql`, eksisterende produkt-API, produkt-audit og ordreeksport. Databasens aktuelle indhold og alle senere migrationer verificeres i fase 0.
+
+API-reference: https://restdocs.e-conomic.com/ – især produkter, varegrupper, kunder, ordrekladder og fakturakladder. Nummerkrav, længder, opdateringssemantik, paging og understøttede referencefelter skal verificeres mod de aktuelle skemaer under implementeringen.
diff --git a/main.py b/main.py
index 674350f..d231102 100644
--- a/main.py
+++ b/main.py
@@ -82,6 +82,7 @@ from app.fixed_price.frontend import views as fixed_price_views
from app.subscriptions.backend import router as subscriptions_api
from app.subscriptions.frontend import views as subscriptions_views
from app.products.backend import router as products_api
+from app.products.backend import economic_routes as economic_catalog_routes
from app.products.frontend import views as products_views
from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views
@@ -323,6 +324,9 @@ async def lifespan(app: FastAPI):
logger.info("✅ Invoice Error Finder sync job scheduled (daily at 05:00)")
logger.info("✅ System initialized successfully")
+ from app.products.backend.economic_catalog import run_catalog_jobs
+ backup_scheduler.scheduler.add_job(run_catalog_jobs, trigger=IntervalTrigger(seconds=15),
+ id='economic_catalog_jobs', max_instances=1, coalesce=True, replace_existing=True)
yield
# Shutdown
backup_scheduler.stop()
@@ -485,6 +489,8 @@ app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
app.include_router(subscriptions_api.router, prefix="/api/v1", tags=["Subscriptions"])
app.include_router(products_api.router, prefix="/api/v1", tags=["Products"])
+app.include_router(economic_catalog_routes.router, prefix="/api/v1", tags=["Economic Catalog"])
+app.include_router(economic_catalog_routes.views)
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
diff --git a/migrations/242_economic_product_integration.sql b/migrations/242_economic_product_integration.sql
new file mode 100644
index 0000000..54661d7
--- /dev/null
+++ b/migrations/242_economic_product_integration.sql
@@ -0,0 +1,152 @@
+-- Additive product integration; no automatic legacy-number backfill.
+BEGIN;
+CREATE TABLE IF NOT EXISTS economic_catalog_connections (
+ id SERIAL PRIMARY KEY, agreement_number TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL, currency VARCHAR(3) NOT NULL,
+ enabled BOOLEAN NOT NULL DEFAULT false, auto_sync BOOLEAN NOT NULL DEFAULT false,
+ sync_interval_minutes INTEGER NOT NULL DEFAULT 15 CHECK(sync_interval_minutes >= 5),
+ next_sync_at TIMESTAMPTZ, last_synced_at TIMESTAMPTZ,
+ number_prefix VARCHAR(10) NOT NULL DEFAULT 'BMC-', next_number BIGINT NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+ALTER TABLE economic_catalog_connections ADD COLUMN IF NOT EXISTS time_unit_number INTEGER;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_connection_id INTEGER REFERENCES economic_catalog_connections(id);
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_product_number VARCHAR(25);
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_product_group_number INTEGER;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_sales_price_reference NUMERIC(18,4);
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_currency VARCHAR(3);
+ALTER TABLE products ADD COLUMN IF NOT EXISTS sales_currency VARCHAR(3) NOT NULL DEFAULT 'DKK';
+-- Legacy cost currency is unknown until explicitly verified; do not assume DKK.
+ALTER TABLE products ADD COLUMN IF NOT EXISTS cost_currency VARCHAR(3);
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_unit_number INTEGER;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS is_active_in_economic BOOLEAN;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS lifecycle_status TEXT NOT NULL DEFAULT 'active';
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_sync_status TEXT NOT NULL DEFAULT 'unlinked';
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_snapshot JSONB;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_last_synced_at TIMESTAMPTZ;
+ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_name_version INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS variant_key TEXT;
+ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS license_term TEXT;
+ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS billing_interval TEXT;
+ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS market TEXT;
+ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS valid_until DATE;
+-- Existing offers are not deduplicated or rewritten. New identified offers cannot collide.
+CREATE UNIQUE INDEX IF NOT EXISTS product_supplier_variant_identity ON product_suppliers
+ (product_id,COALESCE(supplier_code,supplier_name,''),supplier_sku,variant_key,
+ COALESCE(license_term,''),COALESCE(billing_interval,''),COALESCE(market,''))
+ WHERE variant_key IS NOT NULL AND supplier_sku IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS products_economic_identity ON products(economic_connection_id,economic_product_number) WHERE economic_product_number IS NOT NULL;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='products_economic_identity_pair') THEN
+ ALTER TABLE products ADD CONSTRAINT products_economic_identity_pair CHECK ((economic_connection_id IS NULL) = (economic_product_number IS NULL));
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS economic_catalog_references (
+ connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id),
+ kind TEXT NOT NULL CHECK(kind IN ('product-groups','units')), number INTEGER NOT NULL,
+ name TEXT NOT NULL, payload JSONB NOT NULL, category_id INTEGER,
+ allow_new BOOLEAN NOT NULL DEFAULT true, phasing_out BOOLEAN NOT NULL DEFAULT false,
+ PRIMARY KEY(connection_id,kind,number)
+);
+CREATE TABLE IF NOT EXISTS economic_catalog_jobs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id),
+ product_id INTEGER REFERENCES products(id), kind TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending', version INTEGER, payload JSONB NOT NULL DEFAULT '{}',
+ result JSONB NOT NULL DEFAULT '{}', error TEXT, attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), lease_until TIMESTAMPTZ,
+ created_by INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE UNIQUE INDEX IF NOT EXISTS economic_catalog_name_version ON economic_catalog_jobs(product_id,version) WHERE kind='name';
+CREATE UNIQUE INDEX IF NOT EXISTS economic_catalog_single_import ON economic_catalog_jobs(connection_id) WHERE kind='import' AND status IN ('pending','running','retry');
+CREATE UNIQUE INDEX IF NOT EXISTS economic_catalog_single_create ON economic_catalog_jobs(product_id) WHERE kind='create' AND status NOT IN ('failed','cancelled');
+CREATE TABLE IF NOT EXISTS economic_catalog_import_pages (
+ job_id UUID NOT NULL REFERENCES economic_catalog_jobs(id), page_key TEXT NOT NULL,
+ ordinal INTEGER NOT NULL, payload JSONB NOT NULL, next_page TEXT,
+ PRIMARY KEY(job_id,page_key), UNIQUE(job_id,ordinal)
+);
+CREATE TABLE IF NOT EXISTS economic_price_rules (
+ id SERIAL PRIMARY KEY, name TEXT NOT NULL, product_id INTEGER REFERENCES products(id),
+ customer_id INTEGER REFERENCES customers(id), category_id INTEGER,
+ kind TEXT NOT NULL CHECK(kind IN ('fixed','discount','cost_markup')),
+ value NUMERIC(18,4) NOT NULL CHECK(value >= 0), currency VARCHAR(3) NOT NULL DEFAULT 'DKK',
+ priority INTEGER NOT NULL DEFAULT 0, valid_from DATE, valid_to DATE,
+ active BOOLEAN NOT NULL DEFAULT true, version INTEGER NOT NULL DEFAULT 1,
+ created_by INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CHECK(valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from),
+ CHECK(kind <> 'discount' OR value <= 100)
+);
+CREATE TABLE IF NOT EXISTS economic_document_exports (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id),
+ document_key TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('order','invoice')),
+ status TEXT NOT NULL DEFAULT 'pending', snapshot JSONB NOT NULL, response JSONB,
+ economic_number TEXT, error TEXT, created_by INTEGER,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(connection_id,kind,document_key)
+);
+CREATE TABLE IF NOT EXISTS economic_integration_audit (
+ id BIGSERIAL PRIMARY KEY, object_type TEXT NOT NULL, object_id TEXT NOT NULL,
+ action TEXT NOT NULL, user_id INTEGER, changes JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE OR REPLACE FUNCTION protect_exported_ordre_lines() RETURNS trigger AS $$
+BEGIN
+ IF (NEW.lines_json IS DISTINCT FROM OLD.lines_json OR NEW.customer_id IS DISTINCT FROM OLD.customer_id)
+ AND EXISTS (SELECT 1 FROM economic_document_exports WHERE document_key='order-draft:'||OLD.id::text AND status NOT IN ('rejected','archived')) THEN
+ RAISE EXCEPTION 'Exported or uncertain document lines are immutable; create a new draft';
+ END IF;
+ RETURN NEW;
+END; $$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS protect_exported_ordre_lines ON ordre_drafts;
+CREATE TRIGGER protect_exported_ordre_lines BEFORE UPDATE ON ordre_drafts FOR EACH ROW EXECUTE FUNCTION protect_exported_ordre_lines();
+CREATE OR REPLACE FUNCTION protect_exported_time_lines() RETURNS trigger AS $$
+DECLARE target_id INTEGER;
+BEGIN
+ target_id := CASE WHEN TG_OP='INSERT' THEN NEW.order_id ELSE OLD.order_id END;
+ IF EXISTS (SELECT 1 FROM economic_document_exports WHERE document_key='tmodule-order:'||target_id::text AND status NOT IN ('rejected','archived')) THEN
+ RAISE EXCEPTION 'Exported or uncertain time document lines are immutable';
+ END IF;
+ IF TG_OP='DELETE' THEN RETURN OLD; END IF;
+ RETURN NEW;
+END; $$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS protect_exported_time_lines ON tmodule_order_lines;
+CREATE TRIGGER protect_exported_time_lines BEFORE INSERT OR UPDATE OR DELETE ON tmodule_order_lines FOR EACH ROW EXECUTE FUNCTION protect_exported_time_lines();
+CREATE OR REPLACE FUNCTION protect_economic_product_identity() RETURNS trigger AS $$
+BEGIN
+ IF TG_OP='DELETE' AND OLD.economic_product_number IS NOT NULL THEN
+ RAISE EXCEPTION 'Linked products must be deactivated, not deleted';
+ END IF;
+ IF TG_OP='UPDATE' AND OLD.economic_product_number IS NOT NULL AND
+ (NEW.economic_product_number IS DISTINCT FROM OLD.economic_product_number OR NEW.economic_connection_id IS DISTINCT FROM OLD.economic_connection_id)
+ AND current_setting('bmc.economic_rebind', true) IS DISTINCT FROM OLD.id::text THEN
+ RAISE EXCEPTION 'The e-conomic product identity is permanent';
+ END IF;
+ IF TG_OP='DELETE' THEN RETURN OLD; END IF;
+ RETURN NEW;
+END; $$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS protect_economic_product_identity ON products;
+CREATE TRIGGER protect_economic_product_identity BEFORE UPDATE OR DELETE ON products FOR EACH ROW EXECUTE FUNCTION protect_economic_product_identity();
+-- Names stay in Hub. We record the local change but never PUT it to e-conomic.
+CREATE OR REPLACE FUNCTION queue_economic_product_name() RETURNS trigger AS $$
+BEGIN
+ IF NEW.economic_product_number IS NOT NULL AND NEW.name IS DISTINCT FROM OLD.name THEN
+ NEW.economic_name_version := OLD.economic_name_version + 1;
+ INSERT INTO product_audit_log(product_id,event_type,user_id,changes)
+ VALUES(NEW.id,'economic_name_queued',NEW.updated_by,jsonb_build_object('old',OLD.name,'new',NEW.name,'version',NEW.economic_name_version));
+ IF NEW.name IS DISTINCT FROM NEW.economic_snapshot->>'name' THEN
+ NEW.economic_sync_status := 'local_only';
+ END IF;
+ END IF;
+ RETURN NEW;
+END; $$ LANGUAGE plpgsql;
+DROP TRIGGER IF EXISTS queue_economic_product_name ON products;
+CREATE TRIGGER queue_economic_product_name BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION queue_economic_product_name();
+INSERT INTO permissions(code,description,category) VALUES
+ ('economic.catalog.view','Se e-conomic vareintegration','economic'),
+ ('economic.catalog.manage','Administrere e-conomic varer og synkronisering','economic'),
+ ('economic.catalog.rebind','Administrativ omkobling af permanent varereference','economic'),
+ ('economic.pricing.manage','Administrere prisregler','economic'),
+ ('economic.pricing.override','Overskrive beregnet pris','economic'),
+ ('economic.cost.view','Se kost og dækningsbidrag','economic'),
+ ('economic.documents.export','Eksportere økonomiske dokumenter','economic')
+ON CONFLICT(code) DO NOTHING;
+COMMIT;
diff --git a/migrations/243_economic_four_eyes_changes.sql b/migrations/243_economic_four_eyes_changes.sql
new file mode 100644
index 0000000..5f007b7
--- /dev/null
+++ b/migrations/243_economic_four_eyes_changes.sql
@@ -0,0 +1,37 @@
+-- Four-eyes approval for updates of existing e-conomic customers and products.
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS economic_change_requests (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id),
+ target_type TEXT NOT NULL CHECK (target_type IN ('customer', 'product')),
+ target_number VARCHAR(25) NOT NULL,
+ requested_changes JSONB NOT NULL,
+ before_snapshot JSONB NOT NULL,
+ proposed_snapshot JSONB NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'applying', 'approved', 'rejected', 'failed', 'cancelled')),
+ reason TEXT NOT NULL,
+ requested_by_user_id INTEGER NOT NULL REFERENCES users(user_id),
+ requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ reviewed_by_user_id INTEGER REFERENCES users(user_id),
+ reviewed_at TIMESTAMPTZ,
+ review_note TEXT,
+ applied_at TIMESTAMPTZ,
+ response_snapshot JSONB,
+ error TEXT,
+ CHECK (char_length(trim(reason)) >= 10)
+);
+
+CREATE INDEX IF NOT EXISTS idx_economic_change_requests_status
+ ON economic_change_requests(status, requested_at DESC);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_economic_change_requests_one_pending_target
+ ON economic_change_requests(connection_id, target_type, target_number)
+ WHERE status IN ('pending', 'applying');
+
+INSERT INTO permissions(code, description, category) VALUES
+ ('economic.changes.request', 'Anmode om ændring af eksisterende e-conomic-kunder og varer', 'economic'),
+ ('economic.changes.approve', 'Godkende en anden brugers e-conomic-ændringer', 'economic')
+ON CONFLICT(code) DO NOTHING;
+
+COMMIT;
diff --git a/static/js/economic-catalog.js b/static/js/economic-catalog.js
new file mode 100644
index 0000000..6056369
--- /dev/null
+++ b/static/js/economic-catalog.js
@@ -0,0 +1,105 @@
+(() => {
+ 'use strict';
+ const $ = id => document.getElementById(id);
+ const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ let state = {}, products = [], refs = [], offset = 0, saveForm;
+ const labels = {pending:'Afventer',applying:'Udfører',approved:'Godkendt og udført',rejected:'Afvist',cancelled:'Annulleret',running:'Synkroniserer',retry:'Prøver igen',done:'Gennemført',failed:'Fejl',uncertain:'Uafklaret',synced:'Synkroniseret',local_only:'Kun i Hub',unlinked:'Ikke koblet',conflict:'Konflikt'};
+ async function api(path, method='GET', body) {
+ const res = await fetch('/api/v1' + path, {method,headers:{'Content-Type':'application/json'},body:body === undefined ? undefined : JSON.stringify(body)});
+ const data = await res.json();
+ if (!res.ok) throw Error(typeof data.detail === 'string' ? data.detail : `Kaldet fejlede (${res.status})`);
+ return data;
+ }
+ function feedback(message,error=false) { $('ecoFeedback').textContent=message; $('ecoFeedback').className='alert '+(error?'alert-danger':'alert-success'); }
+ const connection = () => Number($('ecoConnection').value);
+ function field(name,label,type='text',value='') { return ``; }
+ function select(name,label,items) { return ``; }
+ function modal(title,fields,save) { $('ecoDialogTitle').textContent=title; $('ecoFields').innerHTML=fields; $('ecoFormError').textContent=''; saveForm=save; $('ecoDialog').showModal(); }
+ async function refresh() {
+ state=await api('/economic/catalog/status');
+ const chosen=$('ecoConnection').value;
+ $('ecoConnection').innerHTML=state.connections.map(c=>``).join('');
+ if(chosen) $('ecoConnection').value=chosen;
+ const active=state.connections.find(c=>c.id===connection())?.enabled;
+ $('ecoMode').textContent=state.read_only||state.dry_run?'Safety mode: kun læsning':!active?'Integration ikke aktiveret':`Live-skrivning aktiveret${state.order_read_only||state.order_dry_run?' · Ordreeksport er dog blokeret af modulindstilling':''}`;
+ $('ecoJobRows').innerHTML=state.jobs.map(j=>`
Dette er en særskilt ændring af varens permanente kobling. Historiske dokumenter ændres ikke. Nuværende nummer: ${esc(p.economic_product_number)}
`+field('confirmation_number','Skriv det nuværende nummer for at bekræfte')+field('number','Nyt eksisterende varenummer på den valgte aftale')+field('reason','Begrundelse (mindst 10 tegn)'),d=>api(`/products/${p.id}/economic-rebind`,'POST',{...d,connection_id:connection()}));}
+ if(b.dataset.price){const p=products.find(x=>x.id===Number(b.dataset.price));modal('Hub-pris: '+p.name,field('sales_price','Standardpris ekskl. moms','number',p.sales_price??'')+field('sales_currency','Valuta','text',p.sales_currency||'DKK')+'
Dette ændrer ikke referenceprisen i e-conomic eller eksisterende aftalepriser.