Compare commits

...

16 Commits
v2.8.1 ... main

Author SHA1 Message Date
Christian
ee274a939a release: v2.8.16 show multiline order descriptions 2026-09-14 15:24:43 +02:00
Christian
b95815de0d release: v2.8.15 format ALSO periods on separate line 2026-09-14 14:18:12 +02:00
Christian
dbdb1c554a release: v2.8.14 add ALSO billing periods 2026-09-14 13:35:38 +02:00
Christian
85fd99c5d8 release: v2.8.13 sync economic order lifecycle 2026-09-13 14:05:06 +02:00
Christian
4971f9721c release: v2.8.12 add complete economic export receipt 2026-09-13 11:41:50 +02:00
Christian
a523bc4ac3 release: v2.8.11 rename subscription draft heading 2026-09-13 11:27:05 +02:00
Christian
2a71e9285a release: v2.8.10 persist economic draft order number 2026-09-13 11:24:18 +02:00
Christian
b3059dced1 release: v2.8.9 preview missing products during export 2026-09-13 09:56:24 +02:00
Christian
87932f8e16 release: v2.8.8 fix economic permissions and order validation 2026-09-13 09:33:36 +02:00
Christian
3e8e5ba1cf release: v2.8.7 economic catalog and four-eyes approval 2026-09-13 02:51:15 +02:00
Christian
631fc6cdca Implement four-eyes approval process for e-conomic changes
- Added migration to create economic_change_requests table for tracking change requests for customers and products.
- Introduced new permissions for requesting and approving changes in e-conomic.
- Developed frontend JavaScript functionality for managing economic catalog, including handling change requests and displaying their statuses.
- Created browser tests to validate UI interactions related to product management and change requests.
- Added unit tests for backend logic to ensure proper handling of product numbers, price rules, and economic write policies.
2026-09-13 02:30:52 +02:00
Christian
13f36a36c0 release: v2.8.6 validate e-conomic order references 2026-09-11 16:58:18 +02:00
Christian
7c1a748012 release: v2.8.5 fix e-conomic order payload 2026-09-11 16:46:51 +02:00
Christian
3d4a7c8421 release: v2.8.4 fix order export safety config 2026-09-11 15:45:46 +02:00
Christian
f251bfebc4 release: v2.8.3 invoice and internet workflows 2026-09-11 13:51:10 +02:00
Christian
056ce7b871 release: v2.8.2 fix Ollama amount normalization 2026-09-09 11:57:37 +02:00
70 changed files with 5992 additions and 405 deletions

View File

@ -0,0 +1,11 @@
# BMC Hub v2.8.10
## Gem e-conomic-ordrenummer
- Gemmer `economic_draft_id` fra det eksisterende eksportflow som kladdens e-conomic-ordrenummer.
- Audit-eventet `export_success` indeholder dermed det faktiske nummer i stedet for `null`.
- Bevarer understøttelse af de øvrige eksisterende feltnavne i eksportsvaret.
## Verifikation
- Regressionstest dækker mapping fra `economic_draft_id` til e-conomic-ordrenummer.

View File

@ -0,0 +1,6 @@
# BMC Hub v2.8.11
## Ordreoverskrift
- Ordrekladder oprettet fra ALSO-abonnementer får overskriften `Abonnementer`.
- Eksisterende åbne abonnementskladder får samme overskrift, når der tilføjes flere godkendte linjer.

View File

@ -0,0 +1,21 @@
# BMC Hub v2.8.12
## Eksportkvittering og afstemning
- Gemmer e-conomic-kunde, valuta, layout, nettobeløb, request, response, ekstern reference, oprettede varer og brugeren, der eksporterede.
- Sender den interne idempotency-reference med ordrekladden til e-conomic.
- En ordre kan ikke længere markeres som eksporteret, bogført eller betalt uden et e-conomic-ordrenummer.
- Eksisterende poster i den ugyldige kombination flyttes til `uncertain`, så de skal afstemmes før en eventuel ny eksport.
- Gemmer og viser den seneste eksportfejl.
## Ordrevisning
- Viser kundenavn sammen med Hub-ID og e-conomic-kundenummer.
- Viser en kompakt eksportkvittering med ordrenummer, periode, valuta, layout, nettobeløb, bruger og reference.
- Viser eksporthistorik som læsbare beskrivelser i stedet for rå JSON.
- Skjuler fakturanummer indtil bogføring og skjuler tvungen geneksport efter gennemført eksport.
- Retter eksisterende ALSO-abonnementskladder til titlen `Abonnementer`.
## Installation
- Kør migration `245_ordre_export_receipt.sql` før applikationen opdateres.

View File

@ -0,0 +1,13 @@
# BMC Hub v2.8.14
## ALSO ordrekladder og e-conomic-reference
- Bruger Hub-ordre-ID som `Eksternt ID` ved eksport til e-conomic.
- Efterlader `Tekst 1` tom for nye ALSO-genererede ordrekladder.
- Tilføjer perioden fra CSV-feltet `Actual Charge Interval` til varelinjens beskrivelse.
- Migration 247 rydder den tidligere systemtekst og opdaterer eksisterende, ikke-eksporterede ALSO-ordrekladder med perioden.
- Bevarer den tidligere eksportreference ved afstemning af allerede påbegyndte eksportforsøg.
## Opgradering
Kør `migrations/247_also_order_external_id.sql` efter deployment. Migrationen ændrer kun ALSO-ordrekladder med status `pending`; eksporterede ordre ændres ikke.

View File

@ -0,0 +1,11 @@
# BMC Hub v2.8.15
## ALSO-perioder på ordrelinjer
- Viser perioden fra `Actual Charge Interval` på en ny linje under produktnavnet.
- Migration 248 retter eksisterende, ikke-eksporterede ALSO-ordrekladder.
- Allerede eksporterede ordre ændres ikke.
## Opgradering
Kør `migrations/248_also_order_period_line_break.sql` efter deployment.

View File

@ -0,0 +1,9 @@
# BMC Hub v2.8.16
## Flerlinjede ordrebeskrivelser
- Viser ordrelinjens produkt og ALSO-periode på separate linjer i både opret- og detaljevisningen.
- Bruger et kompakt tekstområde ved redigering, så linjeskiftet bevares ved gem og eksport til e-conomic.
- Viser linjeskift i forhåndsvisningen af en e-conomic-opdatering.
Der følger ingen ny databasemigration med denne release.

View File

@ -0,0 +1,13 @@
# BMC Hub v2.8.2
## AI Template Builder
- Rettet fejl når Ollama returnerer fakturabeløb som tekst i stedet for numerisk JSON.
- Understøtter nu både danske beløbsformater som `1.471,20 DKK` og engelske formater som `1471.20`.
- Ugyldige eller tomme beløb håndteres uden at afbryde AI-analysen.
- Tilføjet regressionstest for beløbsnormalisering.
## Verifikation
- Regressionstest: 2 bestået.
- Python-syntaks og diff-kontrol bestået.

View File

@ -0,0 +1,38 @@
# BMC Hub v2.8.3
## Internetforbindelser og abonnementer
- Ny hurtig oprettelse med særskilte flows for fysisk forbindelse, delt hovedfiber og BMCnet.
- Delt hovedfiber oprettes med BMC Networks som ejer og uden produkt, abonnement eller abonnementssag.
- BMCnet opretter og forbinder kunde, internetprodukt, abonnement og delt hovedforbindelse i ét flow.
- Kundens adresse bruges til tolerant forslag af delt hovedforbindelse, inklusive mindre staveforskelle.
- Forenklet BMCnet-formular, hvor tekniske værdier arves og kun et valgfrit notefelt vises.
- Forbedret overblik over delte hovedforbindelser, BMCnet-kunder, kapacitet og økonomi.
## Leverandørfakturaer
- Hybrid fakturaudtræk kombinerer tekst, layout og AI med kontrolleret fallback.
- Forbedret udtræk af danske beløb, fakturafelter og varelinjer fra forskellige leverandørformater.
- Template Builder viser fundne data tydeligere og håndterer AI-fejl mere robust.
- Internetfakturaer kan behandles og følges i internetforbindelsesmodulet.
## E-mail og arbejdsgange
- Forenklet valg af mailtype og arbejdsflow med tydeligere handlinger.
- Leverandørfaktura-flow kan registrere internetfakturaer til efterfølgende behandling.
- Forbedret leverandør- og kundematch samt mere kompakt visning af avancerede oplysninger.
## Sager, kontakter og brugerflade
- Kontakter fra andre virksomheder markeres tydeligt på en sag.
- Kontakter kan oprettes direkte fra kontaktsøgningen, også når søgningen ikke giver resultat.
- Bugrapporter bruger en hurtigere og mere stabil lokal indsamling af relevant sideinformation.
- Rettelser til ALSO Cloud-søgning, fælles navigation og øvrige betjeningsdetaljer.
- Plan for AI-baseret kundestemning og sikker fakturabehandling er gemt under `fremtidige planer`.
## Verifikation
- 279 tests bestået og 1 sprunget over.
- Fire kendte, ikke-release-relaterede tests fejler fortsat: tre forældede invoice-error mocks og én databaseafhængig deaktiveringstest.
- Nye regressionstests for hybrid fakturaudtræk og hurtig oprettelse af internetforbindelser består.
- Python- og JavaScript-syntaks samt diff-kontrol bestået.

View File

@ -0,0 +1,13 @@
# BMC Hub v2.8.4
## e-conomic ordreeksport
- Ordremodulet arver nu de globale `ECONOMIC_READ_ONLY` og `ECONOMIC_DRY_RUN`, når de valgfrie `ORDRE_ECONOMIC_*`-indstillinger ikke er defineret.
- Eksplicitte ordreindstillinger har fortsat førsteprioritet.
- Sikker standard bevares: uden eksplicit konfiguration er eksport stadig read-only/dry-run.
## Verifikation
- Regressionstest for både global fallback og eksplicit ordreoverride består.
- Relevante faktura- og internettests består.
- Python-syntaks og diff-kontrol består.

View File

@ -0,0 +1,13 @@
# BMC Hub v2.8.5
## e-conomic ordreeksport
- Henter kundens standardopsætning fra e-conomic før oprettelse af en ordrekladde.
- Medsender obligatoriske betalingsbetingelser, modtageroplysninger og momszone.
- Bruger kundens valuta og adresseoplysninger fra e-conomic.
- Viser e-conomics konkrete valideringsfejl i stedet for kun HTTP-status 400.
## Verifikation
- 9 relevante regressionstests består.
- Python-syntaks og diff-kontrol består.

View File

@ -0,0 +1,13 @@
# BMC Hub v2.8.6
## e-conomic ordreeksport
- Bruger kundens gyldige standardlayout fra e-conomic i stedet for et ugyldigt lokalt standardnummer.
- Kontrollerer produktnumre mod e-conomic inden eksport.
- Ukendte lokale produktnumre eksporteres som fritekstlinjer uden ugyldig produktreference.
- Bevarer produktreferencen, når produktet findes i e-conomic.
## Verifikation
- Fokuserede regressionstests består.
- Python-syntaks og diff-kontrol består.

View File

@ -0,0 +1,21 @@
# BMC Hub v2.8.7
## Varer, priser og e-conomic
- Nyt varekatalog med importkontrol, låste e-conomic-koblinger og synkroniseringsstatus.
- Lokale prisregler, særskilte prisrettigheder og bevarelse af priser på gemte kladder.
- Kontrol af varer og priser før ordreeksport samt afstemning efter usikre eksportforsøg.
- Ændring af eksisterende kunder og varer i e-conomic kræver en begrundet anmodning og godkendelse fra en anden bruger med særskilt rettighed.
- Direkte ekstern skrivning er begrænset til oprettelse af kunder, varer og ordrekladder; godkendte kunde- og vareændringer har en særskilt adgangskontrol.
## Installation
- Kør migration `242_economic_product_integration.sql` og derefter `243_economic_four_eyes_changes.sql` før aktivering af den nye version.
- Migrationerne tilføjer tabeller, felter, indeks, constraints og rettigheder. Eksisterende varenumre kobles ikke automatisk.
- Tilknyt og kontrollér aftalen via **Indstillinger → Varer, priser og synkronisering**, før forbindelsen aktiveres. Se `docs/ECONOMIC_PRODUCT_CATALOG.md`.
## Verifikation
- 69 fokuserede tests består, inklusive seks lokale databasetests med rollback og uden eksterne e-conomic-skrivninger.
- Isoleret browsertest består for varekobling, decimalpriser, livscyklus, prisregler og eksportfanen uden JavaScript-fejl.
- Python- og JavaScript-syntakskontrol samt diff-kontrol består.

View File

@ -0,0 +1,17 @@
# BMC Hub v2.8.8
## Rettelse af adgang til e-conomic-varekataloget
- Tildeler de nye e-conomic-rettigheder til den eksisterende `Administrators`-gruppe.
- Retter fejlen `Missing required permission: economic.catalog.view` for almindelige administratorer.
- Andre grupper og brugere får ikke nye rettigheder automatisk.
## Ordreeksport
- Stopper eksporten før afsendelse, hvis et varenummer ikke findes i e-conomic.
- Viser de manglende varenumre og sender ikke længere prislinjer uden den produktreference, som e-conomic kræver.
## Installation
- Kør migration `244_economic_permissions_backfill.sql` efter migration 242 og 243.
- Migrationen er idempotent og ændrer kun gruppens koblinger til eksisterende rettigheder.

View File

@ -0,0 +1,14 @@
# BMC Hub v2.8.9
## Produktoprettelse ved ordreeksport
- Hvis en ordrelinjes vare ikke findes i e-conomic, åbnes et forhåndsvindue før eksport.
- Vinduet viser varenummer, navn, salgspris, EAN/stregkode, beskrivelse og varegruppe for hver vare, der skal oprettes.
- Brugeren vælger eller kontrollerer varegruppen og godkender samlet med **Opret varer og eksportér**.
- Før godkendelsen udføres kun opslag. Efter godkendelsen oprettes de viste varer og derefter ordrekladden.
- Vareoprettelse kræver fortsat rettigheden `economic.catalog.manage`; ordreeksport kræver `economic.documents.export`.
## Verifikation
- Regressionstest dækker den præcise e-conomic-payload med pris, EAN, beskrivelse og varegruppe.
- Python- og JavaScript-syntakskontrol samt diff-kontrol består.

View File

@ -1 +1 @@
2.8.1
2.8.16

View File

@ -2244,6 +2244,29 @@ def _smart_extract_lines(text: str) -> List[Dict]:
while i < len(lines_arr):
line = lines_arr[i].strip()
# Attach common continuation metadata to the preceding product row.
if items:
ean_match = re.match(r'^EAN\s*:\s*(\d{8,14})\s*$', line, re.IGNORECASE)
kn8_match = re.match(r'^KN8\s*:\s*(\d{6,10})\s*$', line, re.IGNORECASE)
serial_match = re.match(r'^S/N\s*:\s*(\S+)\s*$', line, re.IGNORECASE)
if ean_match:
items[-1]['ean'] = ean_match.group(1)
i += 1
continue
if kn8_match:
items[-1]['kn8'] = kn8_match.group(1)
i += 1
continue
if serial_match:
items[-1].setdefault('serial_numbers', []).append(serial_match.group(1))
i += 1
continue
if re.match(r'^Omvendt betalingspligt', line, re.IGNORECASE):
items[-1]['reverse_charge'] = True
items[-1]['vat_rate'] = 0.0
i += 1
continue
# Skip empty or header lines
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
i += 1
@ -2252,16 +2275,25 @@ def _smart_extract_lines(text: str) -> List[Dict]:
# Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20"
# Whitespace-separated columns: position item_number description quantity unit_price total_price
# Most specific pattern - try first!
layout_match = re.match(r'^\s*(\d{1,2})\s+(\d{4,10})\s+(.+?)\s(\d{1,2})\s+([\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$', line)
amount_pattern = r'-?(?:\d{1,3}(?:[. ]\d{3})+|\d+),\d{2}|-?(?:\d{1,3}(?:,\d{3})+|\d+)\.\d{2}'
layout_match = re.match(
rf'^\s*(\d{{1,3}})\s+([A-Z0-9][A-Z0-9._/-]{{2,29}})\s+(.+?)\s+(\d{{1,3}}(?:[.,]\d+)?)\s+({amount_pattern})\s+({amount_pattern})\s*$',
line,
re.IGNORECASE,
)
if layout_match:
unit_price = _parse_invoice_number(layout_match.group(5))
line_total = _parse_invoice_number(layout_match.group(6))
items.append({
'line_number': len(items) + 1,
'position': layout_match.group(1),
'item_number': layout_match.group(2),
'sku': layout_match.group(2),
'description': layout_match.group(3).strip(),
'quantity': layout_match.group(4),
'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
'quantity': _parse_invoice_number(layout_match.group(4)),
'unit_price': unit_price,
'line_total': line_total,
'total_price': line_total,
'raw_text': line
})
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...")
@ -2366,6 +2398,130 @@ def _smart_extract_lines(text: str) -> List[Dict]:
return items
def _parse_invoice_number(value):
"""Parse common Danish and English invoice number formats safely."""
if value is None or value == '':
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float, Decimal)):
return float(value)
cleaned = re.sub(r'[^0-9,.-]', '', str(value)).strip('.,')
if not cleaned:
return None
if ',' in cleaned and '.' in cleaned:
cleaned = cleaned.replace('.', '').replace(',', '.') if cleaned.rfind(',') > cleaned.rfind('.') else cleaned.replace(',', '')
elif ',' in cleaned:
cleaned = cleaned.replace(',', '.')
elif cleaned.count('.') > 1 or ('.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3):
cleaned = cleaned.replace('.', '')
try:
return float(cleaned)
except ValueError:
return None
def _extract_invoice_totals(text: str) -> Dict:
"""Find explicitly labelled totals without guessing from unrelated amounts."""
labels = {
'subtotal': r'(?:Varebeløb|Subtotal|Netto(?:beløb)?)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'vat_basis': r'(?:Momsgrundlag|VAT basis)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'vat_amount': r'(?:Moms(?:beløb)?|VAT amount)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'total_amount': r'(?:Totalbeløb(?:\s+DKK)?|I alt(?:\s+DKK)?|Amount due)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
}
result = {}
compact = re.sub(r'[ \t]+', ' ', text or '')
summary_row = re.search(
r'Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*'
r'(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+'
r'[\d.,]+%\s+(-?[\d.\s]+,\d{2})',
compact,
re.IGNORECASE,
)
if summary_row:
result.update({
'subtotal': _parse_invoice_number(summary_row.group(1)),
'vat_basis': _parse_invoice_number(summary_row.group(2)),
'vat_amount': _parse_invoice_number(summary_row.group(3)),
'total_amount': _parse_invoice_number(summary_row.group(4)),
})
for key, pattern in labels.items():
if key in result:
continue
matches = list(re.finditer(pattern, compact, re.IGNORECASE))
if matches:
result[key] = _parse_invoice_number(matches[-1].group(1))
return result
def _hybrid_validate_invoice(extracted: Dict, text: str) -> Dict:
"""Cross-check parser and AI output and attach actionable review metadata."""
result = dict(extracted or {})
ai_lines = result.get('lines') if isinstance(result.get('lines'), list) else []
parsed_lines = _smart_extract_lines(text or '')
if len(parsed_lines) > len(ai_lines):
result['lines'] = parsed_lines
selected_lines = parsed_lines
line_source = 'layout_text'
else:
selected_lines = ai_lines
line_source = 'ai_or_template'
totals = _extract_invoice_totals(text or '')
for key in ('total_amount', 'vat_amount'):
current = _parse_invoice_number(result.get(key))
if current is None and totals.get(key) is not None:
result[key] = totals[key]
elif current is not None:
result[key] = current
warnings = []
invalid_rows = []
line_sum = 0.0
for index, line in enumerate(selected_lines, start=1):
if not isinstance(line, dict):
invalid_rows.append(index)
continue
quantity = _parse_invoice_number(line.get('quantity'))
unit_price = _parse_invoice_number(line.get('unit_price'))
line_total = _parse_invoice_number(line.get('line_total', line.get('total_price')))
if line_total is not None:
line_sum += line_total
if quantity is not None and unit_price is not None and line_total is not None:
if abs((quantity * unit_price) - line_total) > 0.05:
invalid_rows.append(index)
subtotal = totals.get('subtotal')
vat_basis = totals.get('vat_basis')
vat_amount = totals.get('vat_amount', _parse_invoice_number(result.get('vat_amount')))
total_amount = totals.get('total_amount', _parse_invoice_number(result.get('total_amount')))
if invalid_rows:
warnings.append(f"Kontrollér beregningen på varelinje(r): {', '.join(map(str, invalid_rows))}")
if selected_lines and subtotal is not None and abs(line_sum - subtotal) > 0.05:
warnings.append(f"Varelinjer summerer til {line_sum:.2f}, men varebeløbet er {subtotal:.2f}")
if subtotal is not None and vat_amount is not None and total_amount is not None and abs((subtotal + vat_amount) - total_amount) > 0.05:
warnings.append(f"Varebeløb + moms stemmer ikke med totalen ({subtotal:.2f} + {vat_amount:.2f} != {total_amount:.2f})")
if vat_basis is not None and vat_amount is not None and abs((vat_basis * 0.25) - vat_amount) > 0.05:
warnings.append(f"Moms {vat_amount:.2f} stemmer ikke med 25% af momsgrundlaget {vat_basis:.2f}")
if not selected_lines:
warnings.append("Ingen sikre varelinjer fundet")
result['_hybrid_validation'] = {
'status': 'needs_review' if warnings else 'validated',
'requires_review': bool(warnings),
'line_source': line_source,
'line_count': len(selected_lines),
'line_sum': round(line_sum, 2),
'document_totals': totals,
'warnings': warnings,
'methods': ['layout_text', 'ai'] if ai_lines else ['layout_text', 'template'],
}
if warnings:
result['_validation_warning'] = ' · '.join(warnings)
result['_validation_details'] = result['_hybrid_validation']
return result
# ========== CRUD OPERATIONS ==========
@router.get("/supplier-invoices")
@ -2838,6 +2994,7 @@ async def get_file_extracted_data(file_id: int):
"_validation_warning": llm_json_data.get('_validation_warning'),
"_vat_warning": llm_json_data.get('_vat_warning'),
"_validation_details": llm_json_data.get('_validation_details'),
"_hybrid_validation": llm_json_data.get('_hybrid_validation'),
}
elif extraction:
# Fallback to extraction table columns if no LLM JSON
@ -3637,6 +3794,93 @@ async def search_vendor_by_info(request: Dict):
raise HTTPException(status_code=500, detail=str(e))
_OWN_COMPANY_CVRS = {"29522790", "14416285"}
_OWN_COMPANY_MARKERS = ("bmc denmark", "lejrvej 39", "3500 værløse")
def _pattern_matches_value(pattern: str, text: str, expected) -> bool:
try:
match = re.search(pattern, text or "", re.IGNORECASE | re.MULTILINE)
if not match or not match.groups():
return False
actual = str(match.group(1)).strip()
if isinstance(expected, (int, float, Decimal)):
parsed = _parse_invoice_number(actual)
return parsed is not None and abs(parsed - float(expected)) < 0.01
return re.sub(r"\D", "", actual) == re.sub(r"\D", "", str(expected)) if re.search(r"\d", str(expected)) else actual == str(expected)
except re.error:
return False
def _build_template_builder_result(pdf_text: str, ai_result: Optional[Dict] = None, vendor: Optional[Dict] = None) -> Dict:
"""Build safe template suggestions; AI may suggest, but text evidence wins."""
text = pdf_text or ""
result = dict(ai_result or {})
invoice_pattern = r"(?:Faktura(?:nr\.?|nummer)?|Nummer)\s*[:#]?\s*(\d{4,})"
invoice_match = re.search(invoice_pattern, text, re.IGNORECASE | re.MULTILINE)
if invoice_match:
result["invoice_number"] = {"value": invoice_match.group(1), "pattern": invoice_pattern}
date_pattern = r"(?:Fakturadato|Dato)\s*[:#]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})"
date_match = re.search(date_pattern, text, re.IGNORECASE | re.MULTILINE)
if date_match:
result["invoice_date"] = {"value": date_match.group(1), "pattern": date_pattern}
totals = _extract_invoice_totals(text)
total = totals.get("total_amount")
total_patterns = [
(r"Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*"
r"-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+[\d.,]+%\s+(-?[\d.\s]+,\d{2})"),
r"(?:Totalbeløb|I alt|Amount due)\s*(?:DKK)?\s*:?\s*(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})",
]
if total is not None:
for pattern in total_patterns:
if _pattern_matches_value(pattern, text, total):
result["total_amount"] = {"value": f"{total:.2f}", "pattern": pattern}
break
own_cvrs = set(_OWN_COMPANY_CVRS)
configured_own_cvr = re.sub(r"\D", "", str(getattr(settings, "OWN_CVR", "")))
if configured_own_cvr:
own_cvrs.add(configured_own_cvr)
vendor_cvr = re.sub(r"\D", "", str((vendor or {}).get("cvr_number") or ""))
cvr_candidates = re.findall(r"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*(\d{8})", text, re.IGNORECASE)
safe_cvrs = [cvr for cvr in cvr_candidates if cvr not in own_cvrs]
selected_cvr = vendor_cvr if vendor_cvr and vendor_cvr in safe_cvrs else (safe_cvrs[0] if safe_cvrs else "")
if selected_cvr:
cvr_pattern = rf"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*({re.escape(selected_cvr)})"
if _pattern_matches_value(cvr_pattern, text, selected_cvr):
result["cvr"] = {"value": selected_cvr, "pattern": cvr_pattern}
else:
result.pop("cvr", None)
result.pop("vendor_cvr", None)
candidates = []
vendor_name = str((vendor or {}).get("name") or "").strip()
if vendor_name and vendor_name.lower() in text.lower():
candidates.append(vendor_name)
if selected_cvr:
candidates.append(selected_cvr)
candidates.extend(re.findall(r"\b[\w.+-]+@([\w.-]+\.[A-Za-z]{2,})\b", text))
candidates.extend(re.findall(r"\b(?:www\.)?([A-Za-z0-9-]+\.(?:dk|com|eu|net))\b", text, re.IGNORECASE))
safe_patterns = []
for candidate in candidates:
candidate = str(candidate).strip()
lowered = candidate.lower()
if not candidate or any(marker in lowered for marker in _OWN_COMPANY_MARKERS) or re.sub(r"\D", "", candidate) in own_cvrs:
continue
if candidate.lower() not in {item.lower() for item in safe_patterns}:
safe_patterns.append(candidate)
result["detection_patterns"] = safe_patterns[:5]
result["lines_start"] = {"pattern": r"Nr\.?\s+Varenr\.?\s+Tekst"}
result["lines_end"] = {"pattern": r"(?:Varebeløb|Subtotal|Totalbeløb)"}
line_items = _smart_extract_lines(text)
result["line_count"] = len(line_items)
result["line_items"] = line_items
return result
@router.post("/supplier-invoices/ai/analyze")
async def ai_analyze_invoice(request: Dict):
"""Brug AI til at analysere faktura og foreslå template felter"""
@ -3650,6 +3894,7 @@ async def ai_analyze_invoice(request: Dict):
# Build enhanced PDF text with instruction
from app.core.config import settings
excerpt = pdf_text if len(pdf_text) <= 14000 else f"{pdf_text[:7000]}\n[...midt udeladt...]\n{pdf_text[-7000:]}"
enhanced_text = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
@ -3659,7 +3904,7 @@ REQUIRED STRUKTUR (alle felter skal med):
"invoice_number": "5082481",
"invoice_date": "24/10-25",
"total_amount": "1471.20",
"cvr": "29522790",
"cvr": "26686091",
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
"lines_start": "Nr.VarenrTekst",
"lines_end": "Subtotal"
@ -3690,7 +3935,7 @@ VIGTIGT:
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk data
PDF TEKST:
{pdf_text[:2000]}
{excerpt}
RETURNER KUN JSON - intet andet!"""
@ -3705,8 +3950,13 @@ RETURNER KUN JSON - intet andet!"""
# Do not expose that dict as a successful analysis to the browser.
raise HTTPException(status_code=502, detail=str(result["error"]))
logger.info(f"✅ AI analyse gennemført: {result}")
return result
vendor = None
if vendor_id:
rows = execute_query("SELECT id, name, cvr_number FROM vendors WHERE id = %s LIMIT 1", (vendor_id,))
vendor = rows[0] if rows else None
safe_result = _build_template_builder_result(pdf_text, result, vendor)
logger.info("✅ AI analyse gennemført og valideret mod fakturateksten")
return safe_result
except HTTPException:
raise
@ -4916,6 +5166,7 @@ async def reprocess_uploaded_file(file_id: int):
if template_id and confidence >= 0.5:
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
extracted_fields = template_service.extract_fields(text, template_id)
extracted_fields = _hybrid_validate_invoice(extracted_fields, text)
# Check if this is an invoice2data template (ID -1)
is_invoice2data = (template_id == -1)
@ -4992,7 +5243,7 @@ async def reprocess_uploaded_file(file_id: int):
extracted_fields.get('currency', 'DKK'),
1.0, # invoice2data always 100% confidence
json.dumps(extracted_fields), # llm_response_json
'extracted') # status
'needs_review' if extracted_fields.get('_hybrid_validation', {}).get('requires_review') else 'validated')
)
# Insert line items if extracted
@ -5067,7 +5318,8 @@ async def reprocess_uploaded_file(file_id: int):
raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}")
extracted_fields = llm_result
extracted_fields = _hybrid_validate_invoice(llm_result, text)
llm_result = extracted_fields
confidence = llm_result.get('confidence', 0.75)
# Post-process: clear own CVR(s) if AI mistakenly returned them
@ -5128,7 +5380,7 @@ async def reprocess_uploaded_file(file_id: int):
llm_result.get('document_type', 'invoice'),
confidence,
json.dumps(llm_result),
'extracted')
'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated')
)
# Insert line items if extracted

View File

@ -2916,7 +2916,9 @@ async function reviewExtractedData(fileId) {
let aiData = null;
if (ext.llm_response_json) {
try {
aiData = JSON.parse(ext.llm_response_json);
aiData = typeof ext.llm_response_json === 'string'
? JSON.parse(ext.llm_response_json)
: ext.llm_response_json;
} catch (e) {
console.error('Failed to parse llm_response_json:', e);
}
@ -2929,8 +2931,14 @@ async function reviewExtractedData(fileId) {
.trim();
// Check for validation warnings
const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0;
const hybridValidation = aiData?._hybrid_validation || null;
const hybridWarnings = hybridValidation?.warnings || [];
const hasValidationIssues = hybridValidation?.requires_review || aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = hybridValidation?.status === 'validated' || (!hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0);
const sourceLabels = {
layout_text: 'PDF-layout',
ai_or_template: 'AI/template'
};
// Build modal content
let modalContent = `
@ -2946,14 +2954,15 @@ async function reviewExtractedData(fileId) {
${hasValidationIssues ? `
<div class="alert alert-warning">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Beløbs-validering</h6>
${aiData._validation_warning ? `<p class="mb-1">⚠️ ${aiData._validation_warning}</p>` : ''}
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Kræver manuel kontrol</h6>
${hybridWarnings.map(warning => `<p class="mb-1">⚠️ ${escapeHtml(warning)}</p>`).join('')}
${!hybridWarnings.length && aiData._validation_warning ? `<p class="mb-1">⚠️ ${escapeHtml(aiData._validation_warning)}</p>` : ''}
${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''}
</div>
` : allValidationsPassed ? `
<div class="alert alert-success">
<h6 class="alert-heading"><i class="bi bi-check-circle me-2"></i>Beløbs-validering</h6>
<p class="mb-0">✅ Varelinjer summer korrekt til subtotal<br>✅ Moms beregning er korrekt (25%)</p>
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>Automatisk kontrol bestået</h6>
<p class="mb-0">${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}</p>
</div>
` : ''}

View File

@ -207,6 +207,8 @@
3. Systemet laver automatisk patterns!
</div>
<div id="aiLineItemsResult" class="d-none mb-3"></div>
<button class="btn btn-success w-100 mb-3" onclick="autoGenerateTemplate()" id="aiGenerateBtn">
<i class="bi bi-magic me-2"></i>🤖 AI Auto-generer Template
</button>
@ -1466,6 +1468,7 @@ async function autoGenerateTemplate() {
const linesEnd = result.lines_end?.pattern || result.lines_end;
document.getElementById('linesEndPattern').value = linesEnd;
}
renderAiLineItems(result.line_items || [], result.line_count || 0);
// Note: Intentionally NOT setting line_pattern - multi-line extraction handles it
btn.innerHTML = '<i class="bi bi-check-circle me-2"></i>✅ AI analyse færdig!';
@ -1482,6 +1485,57 @@ async function autoGenerateTemplate() {
}
}
function templateEscapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderAiLineItems(lines, reportedCount) {
const container = document.getElementById('aiLineItemsResult');
if (!container) return;
const count = Array.isArray(lines) ? lines.length : Number(reportedCount || 0);
if (!count) {
container.className = 'alert alert-warning mb-3';
container.innerHTML = '<i class="bi bi-exclamation-triangle me-2"></i><strong>Ingen varelinjer fundet.</strong> Kontrollér teksten eller angiv et linjemønster manuelt.';
return;
}
const labels = {
line_number: 'Linje', position: 'Position', item_number: 'Varenummer', sku: 'SKU',
description: 'Beskrivelse / model', quantity: 'Antal', unit_price: 'Stykpris',
line_total: 'Linjebeløb', total_price: 'Samlet beløb', ean: 'EAN', kn8: 'KN8',
serial_numbers: 'Serienummer', reverse_charge: 'Omvendt betalingspligt',
vat_rate: 'Momssats', raw_text: 'Rå tekst'
};
const itemCards = (lines || []).map((line, index) => {
const fields = Object.entries(line)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => {
let displayValue = Array.isArray(value) ? value.join(', ') : value;
if (typeof value === 'boolean') displayValue = value ? 'Ja' : 'Nej';
return `<div class="col-md-6 col-xl-4 mb-2">
<div class="small text-muted">${templateEscapeHtml(labels[key] || key)}</div>
<div class="text-break">${templateEscapeHtml(displayValue)}</div>
</div>`;
}).join('');
const heading = line.item_number || line.sku || `Linje ${index + 1}`;
return `<div class="card mb-2">
<div class="card-header py-2 fw-semibold">Vare ${index + 1}: ${templateEscapeHtml(heading)}</div>
<div class="card-body py-2"><div class="row">${fields}</div></div>
</div>`;
}).join('');
container.className = 'alert alert-success mb-3 p-3';
container.innerHTML = `
<div class="fw-bold mb-2"><i class="bi bi-check-circle me-2"></i>AI fandt ${count} varelinjer</div>
<div>${itemCards}</div>`;
}
async function saveTemplate() {
const vendorId = document.getElementById('vendorSelect').value;
const templateName = document.getElementById('templateName').value;

View File

@ -94,17 +94,18 @@ def _rate_limit(user_id: int) -> None:
def _resolve_customer_id() -> int:
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
if configured_row:
return int(configured_row["id"])
# Resolve our own company by identity, never by an arbitrary/old numeric ID.
# Customer IDs differ between environments and ID 1 may belong to a supplier.
named_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE LOWER(name) = LOWER(%s)
ORDER BY id ASC
SELECT c.id
FROM customers c
LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL
WHERE LOWER(TRIM(c.name)) = LOWER(%s)
AND c.deleted_at IS NULL
AND COALESCE(c.is_active, TRUE) = TRUE
GROUP BY c.id
ORDER BY COUNT(s.id) DESC, c.id ASC
LIMIT 1
""",
("BMC Networks",),
@ -112,11 +113,23 @@ def _resolve_customer_id() -> int:
if named_row:
return int(named_row["id"])
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
if fallback:
return int(fallback["id"])
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE id = %s
AND (
LOWER(name) LIKE '%%bmc networks%%'
OR REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ('29522790', '14416285')
)
""",
(configured_id,),
)
if configured_row:
return int(configured_row["id"])
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
raise HTTPException(status_code=500, detail="BMC Networks-kunden blev ikke fundet; bugrapporten blev ikke oprettet")
@router.post("/bug-reports", response_model=BugReportResult)
@ -176,8 +189,10 @@ async def create_bug_report(payload: BugReportPayload, request: Request):
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large")
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png"
filename = f"screenshot.{extension}"
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.{extension}")
_create_sag_file_record(sag_id, filename, content_type, size, stored_name)
# Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")

View File

@ -0,0 +1,59 @@
"""The deliberately narrow set of external e-conomic mutations BMC Hub permits."""
from contextlib import contextmanager
from contextvars import ContextVar
import re
from fastapi import HTTPException
# Reading any endpoint is allowed. Writes are an explicit allow-list, not a
# deny-list, so a new endpoint cannot accidentally become writable.
ALLOWED_WRITES = {
('POST', 'customers'),
('POST', 'products'),
('POST', 'orders/drafts'),
}
_approved_four_eyes_write = ContextVar('approved_four_eyes_write', default=False)
_approved_order_draft_update = ContextVar('approved_order_draft_update', default=False)
_FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$')
_ORDER_DRAFT_UPDATE = re.compile(r'^orders/drafts/[0-9]+$')
@contextmanager
def approved_four_eyes_write():
"""Permit one approved customer/product update in the current async context."""
token = _approved_four_eyes_write.set(True)
try:
yield
finally:
_approved_four_eyes_write.reset(token)
@contextmanager
def approved_order_draft_update():
"""Permit one user-confirmed update of an existing e-conomic order draft."""
token = _approved_order_draft_update.set(True)
try:
yield
finally:
_approved_order_draft_update.reset(token)
def assert_economic_write_allowed(method: str, path: str) -> None:
clean_path = path.split('?', 1)[0].strip('/')
operation = (method.upper(), clean_path)
approved_update = (
operation[0] == 'PUT'
and _FOUR_EYES_UPDATES.fullmatch(clean_path)
and _approved_four_eyes_write.get()
)
approved_draft_update = (
operation[0] == 'PUT'
and _ORDER_DRAFT_UPDATE.fullmatch(clean_path)
and _approved_order_draft_update.get()
)
if operation not in ALLOWED_WRITES and not approved_update and not approved_draft_update:
raise HTTPException(
status_code=403,
detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder',
)

View File

@ -672,10 +672,27 @@
renderJobLines();
};
const renderCustomerSearchResults = (lineId) => {
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateCustomerSearchResults = (lineId) => {
const host = document.querySelector(`[data-company-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderCustomerSearchResults(lineId);
};
const renderMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineCustomerSelections[lineId];
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return `
@ -699,16 +716,8 @@
` : `
<div class="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div>
`}
<div class="map-results mb-2">
${results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
<div class="map-results mb-2" data-company-results-line-id="${lineId}">
${renderCustomerSearchResults(lineId)}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-company-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -720,10 +729,27 @@
`;
};
const renderProductSearchResults = (lineId) => {
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateProductSearchResults = (lineId) => {
const host = document.querySelector(`[data-product-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderProductSearchResults(lineId);
};
const renderProductMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineProductSelections[lineId];
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return `
@ -747,16 +773,8 @@
` : `
<div class="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div>
`}
<div class="map-results mb-2">
${results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
<div class="map-results mb-2" data-product-results-line-id="${lineId}">
${renderProductSearchResults(lineId)}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-product-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -824,13 +842,14 @@
state.lineCustomerSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) {
state.lineCustomerSearchResults[lineId] = [];
renderJobLines();
updateCustomerSearchResults(lineId);
return;
}
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
if (state.lineCustomerSearchQueries[lineId] !== query) return;
state.lineCustomerSearchResults[lineId] = rows || [];
renderJobLines();
updateCustomerSearchResults(lineId);
};
const saveCompanyMapping = async (lineId) => {
@ -873,13 +892,14 @@
state.lineProductSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) {
state.lineProductSearchResults[lineId] = [];
renderJobLines();
updateProductSearchResults(lineId);
return;
}
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
if (state.lineProductSearchQueries[lineId] !== query) return;
state.lineProductSearchResults[lineId] = rows || [];
renderJobLines();
updateProductSearchResults(lineId);
};
const saveProductMapping = async (lineId) => {

View File

@ -77,6 +77,79 @@
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-flow-picker {
width: 100%;
}
.emails-v2-flow-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 0.65rem;
}
.emails-v2-flow-choice {
position: relative;
min-height: 74px;
padding: 0.72rem 0.8rem;
border: 1px solid var(--border-color);
border-radius: 10px;
background: #52606d;
color: #fff;
text-align: left;
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
}
.emails-v2-flow-choice:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
.emails-v2-flow-choice.recommended {
border: 3px solid #fff;
outline: 3px solid var(--accent);
}
.emails-v2-flow-choice.flow-support { background: #1769aa; }
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
.emails-v2-flow-choice.flow-archive { background: #59636e; }
.emails-v2-flow-choice:disabled {
opacity: 0.42;
box-shadow: none;
}
.emails-v2-flow-choice .flow-title {
display: block;
font-weight: 700;
}
.emails-v2-flow-choice .flow-description {
display: block;
margin-top: 0.22rem;
color: rgba(255, 255, 255, 0.88);
font-size: 0.76rem;
line-height: 1.25;
}
.emails-v2-flow-choice .flow-badge {
position: absolute;
top: -0.55rem;
right: 0.45rem;
padding: 0.12rem 0.42rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.66rem;
font-weight: 700;
}
@media (max-width: 1500px) {
.emails-v2-flow-grid {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
@ -1074,7 +1147,19 @@
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
const effects = [];
if (isApprovedSupplierInvoice) {
effects.push(
'Beholder tilknytningen til den valgte leverandør',
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
'Aflæser fakturanummer, beløb og varelinjer',
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
'Markerer emailen som behandlet'
);
} else {
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
@ -1084,42 +1169,36 @@
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
}
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
<div class="small text-uppercase fw-bold text-muted mb-1">Anbefalet behandling</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<div class="d-flex justify-content-between small mt-2 mb-1">
<span>${escapeHtml(confidenceLabel)}</span>
<strong>${confidencePercent}%</strong>
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
</div>
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Vælg flowet øverst på siden.</div>
<div class="mt-3">
<div class="small fw-semibold mb-2">Vurdering og handlinger</div>
<div class="mt-3">
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-3">
<ol class="email-ai-actions mb-2">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : `
<button id="v2ApproveActions" class="btn btn-success w-100">
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
</button>`}
` : `
<div class="alert alert-secondary py-2 small mb-0">
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
</div>` : ''}
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
</div>
`}
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
</div>
`;
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
bindIdentityDecisionActions();
}
@ -1129,7 +1208,12 @@
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
await runQuickAction('create_supplier_invoice', button);
} else {
await executeWorkflowsCurrent();
}
} finally {
if (document.body.contains(button)) {
button.disabled = false;
@ -1158,6 +1242,8 @@
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
const emailMeta = preview.email || {};
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
&& Boolean(state.selectedEmail?.supplier_id);
const systemHtml = system.map((row) => {
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
@ -1169,7 +1255,15 @@
`;
}).join('');
const matchingHtml = matching.length
const matchingHtml = usesSupplierApprovalFlow
? `<div class="emails-v2-kv">
<div class="k">Godkendelsesflow</div>
<div class="v">
<span class="badge bg-success">Leverandørfaktura</span>
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
</div>
</div>`
: matching.length
? matching.map((wf) => `
<div class="emails-v2-kv">
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
@ -1604,26 +1698,48 @@
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier;
const supplierIdentity = `${email.supplier_name || ''} ${email.sender_email || ''}`.toLowerCase();
const isGlobalConnect = supplierIdentity.includes('globalconnect');
const classification = String(email.classification || '').toLowerCase();
const recommendedFlow = classification === 'invoice'
? 'create_supplier_invoice'
: (classification === 'customer_email' ? 'create_customer_case' : '');
const recommendationBadge = (flow) => recommendedFlow === flow
? '<span class="flow-badge">ANBEFALET</span>'
: '';
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
<i class="bi bi-person-workspace me-1"></i>Kundesag
<div class="emails-v2-flow-picker">
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
${recommendationBadge('create_customer_case')}
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
<span class="flow-description">Opret en almindelig kundesag</span>
</button>
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
${recommendationBadge('create_supplier_invoice')}
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
<span class="flow-description">${isGlobalConnect ? 'Internetfaktura genkendt · opdaterer Internetforbindelser' : 'Aflæs PDF og kør leverandørflowet'}</span>
</button>
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
${recommendationBadge('create_accounting_case')}
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
<span class="flow-description">Opret kundesag hos Bogholderi</span>
</button>
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
<i class="bi bi-archive me-1"></i>Arkivér mail
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
${recommendationBadge('archive_entity_mail')}
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
<span class="flow-description">Ingen sag eller anden behandling</span>
</button>
</div>
</div>
`;
mailBody.className = 'emails-v2-mail-body';
@ -1661,8 +1777,14 @@
</div>
</div>
<details class="emails-v2-card">
<summary class="fw-semibold" style="cursor:pointer;">
<i class="bi bi-three-dots me-1"></i>Flere handlinger
</summary>
<div class="mt-3">
${email.linked_case_id ? `
<div class="emails-v2-card emails-primary-action">
<div class="emails-primary-action rounded p-3 mb-3">
<h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
@ -1671,8 +1793,9 @@
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button>
</div>` : `
<div class="emails-v2-card emails-primary-action">
<h6>Opret sag fra denne email</h6>
<details class="border rounded p-3 mb-3">
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
<div class="mt-3">
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
@ -1686,15 +1809,16 @@
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div>
</div>`}
</div>
</details>`}
<div class="emails-v2-card">
<div class="border rounded p-3 mb-3">
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
<div id="v2SagResults" class="emails-v2-sag-results"></div>
</div>
<div class="emails-v2-card">
<div class="border rounded p-3 mb-3">
<h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
@ -1710,7 +1834,7 @@
</div>
</div>
<details class="emails-v2-card">
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
@ -1721,7 +1845,7 @@
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="emails-v2-card">
<details class="border rounded p-3 mb-3">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
@ -1743,7 +1867,7 @@
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</details>
<details class="emails-v2-card">
<details class="border rounded p-3">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
email_id: email.id,
@ -1755,6 +1879,8 @@
linked_case_id: email.linked_case_id,
}, null, 2))}</pre>
</details>
</div>
</details>
`;
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));

View File

@ -275,6 +275,21 @@ def _extract_period_start(value: Any) -> Optional[str]:
return _parse_date_candidate(match.group(1))
def _format_charge_period(value: Any) -> Optional[str]:
"""Return an explicit ALSO service period without inferring a missing end date."""
text = _normalized_text(value)
if not text:
return None
match = re.match(r"\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*-\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*$", text)
if not match:
return None
start = _parse_date_candidate(match.group(1))
end = _parse_date_candidate(match.group(2))
if not start or not end:
return None
return f"{datetime.strptime(start, '%Y-%m-%d').strftime('%d.%m.%Y')} - {datetime.strptime(end, '%Y-%m-%d').strftime('%d.%m.%Y')}"
def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
product_name = _normalize_match_key(line.get("product_name"))
if "microsoftorganizationtenant" not in product_name:
@ -319,7 +334,8 @@ class AlsoService:
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
"currency": ["currency", "valuta"],
"billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"],
"charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
# The CSV's Actual Charge Interval is the authoritative service period.
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"],
"billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"],
"source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
@ -2008,6 +2024,10 @@ class AlsoService:
quantity = Decimal("1")
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
description = line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement"
charge_period = _format_charge_period(line.get("charge_interval"))
if charge_period:
description = f"{description}\nPeriode: {charge_period}"
draft_lines.append(
{
@ -2015,7 +2035,7 @@ class AlsoService:
"source_type": "also_cloud",
"source_id": int(line["id"]),
"reference_id": int(line["import_job_id"]),
"description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
"description": description,
"quantity": float(quantity),
"unit": "stk",
"unit_price": float(unit_price),
@ -2032,6 +2052,7 @@ class AlsoService:
"also_material_number": line.get("material_number"),
"also_vendor": line.get("vendor"),
"also_import_job_id": int(line.get("import_job_id")),
"billing_period": charge_period,
},
}
)
@ -2067,7 +2088,7 @@ class AlsoService:
]
execute_query(
"""UPDATE ordre_drafts
SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])),
)
@ -2090,10 +2111,10 @@ class AlsoService:
RETURNING id
""",
(
f"ALSO Cloud {customer_name} - {period_key}",
"Abonnementer",
customer_id,
_json_dumps(draft_lines),
"Genereret fra ALSO Cloud Billing approval",
None,
1,
approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}),

View File

@ -1270,6 +1270,26 @@ class QuickBmcnetCreatePayload(BaseModel):
mark_gateway: bool = False
class QuickInternetSetupPayload(BaseModel):
customer_id: Optional[int] = None
product_id: Optional[int] = None
start_date: Optional[date] = None
shared_fiber: bool = False
connection_id: Optional[int] = None
connection_name: Optional[str] = None
vendor_id: Optional[int] = None
circuit_number: Optional[str] = None
address: Optional[str] = None
technology: Optional[str] = None
download_mbps: Optional[int] = None
upload_mbps: Optional[int] = None
monthly_cost: float = 0
unit_price: Optional[float] = None
billing_interval: str = "monthly"
billing_day: int = 1
notes: Optional[str] = None
class DelefiberProductPricePayload(BaseModel):
product_id: int
monthly_price: float
@ -3098,6 +3118,168 @@ async def remove_delefiber_product_price(connection_id: int, product_id: int):
return {"deleted": True}
@router.post("/internet-connections/quick-setup")
async def create_quick_internet_setup(payload: QuickInternetSetupPayload):
"""Create a connection, and for customer connections also create a draft subscription."""
customer_id = payload.customer_id
if payload.shared_fiber:
own_customer = execute_query_single(
"""
SELECT c.id, c.name
FROM customers c
WHERE c.deleted_at IS NULL AND c.is_active = true
AND LOWER(TRIM(c.name)) IN ('bmc networks', 'bmc networks aps')
ORDER BY
(SELECT COUNT(*) FROM sag_sager s WHERE s.customer_id = c.id) DESC,
(SELECT COUNT(*) FROM internet_connections_connections ic WHERE ic.customer_id = c.id AND ic.deleted_at IS NULL) DESC,
c.id ASC
LIMIT 1
"""
)
if not own_customer:
raise HTTPException(status_code=409, detail="BMC Networks-kunden blev ikke fundet")
customer_id = int(own_customer["id"])
if not customer_id:
raise HTTPException(status_code=400, detail="Vælg en kunde")
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND is_active = true",
(customer_id,),
)
if not customer:
raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
if payload.shared_fiber:
name = str(payload.connection_name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Skriv et navn til hovedforbindelsen")
connection = await create_connection(ConnectionCreatePayload(
name=name,
vendor_id=payload.vendor_id,
customer_id=customer_id,
address=str(payload.address or "").strip() or None,
status="planned",
monthly_cost=float(payload.monthly_cost or 0),
sales_price=0,
technology=str(payload.technology or "").strip() or None,
circuit_number=str(payload.circuit_number or "").strip() or None,
download_mbps=payload.download_mbps,
upload_mbps=payload.upload_mbps,
contract_start=payload.start_date,
notes=str(payload.notes or "").strip() or None,
allocation_model="shared",
value_type="delefiber",
value_label="Delefiber",
is_manual_shared=True,
))
return {"success": True, "sag": None, "subscription": None, "connection": connection}
if not payload.product_id or not payload.start_date:
raise HTTPException(status_code=400, detail="Vælg internetprodukt og startdato")
product = execute_query_single(
"""
SELECT id, name, short_description, sales_price, attributes_json, type
FROM products
WHERE id = %s AND deleted_at IS NULL
""",
(payload.product_id,),
)
if not product:
raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet")
profile = build_network_product_profile(product, fallback_text=product.get("short_description"))
if profile.get("kind") != "internet_access":
raise HTTPException(status_code=400, detail="Det valgte produkt er ikke markeret som internetprodukt")
existing_connection = None
if payload.connection_id:
existing_connection = execute_query_single(
"""
SELECT id, name, customer_id, subscription_id
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(payload.connection_id,),
)
if not existing_connection:
raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet")
if existing_connection.get("subscription_id"):
raise HTTPException(status_code=409, detail="Forbindelsen har allerede et abonnement")
if existing_connection.get("customer_id") not in (None, customer_id):
raise HTTPException(status_code=409, detail="Forbindelsen er tilknyttet en anden kunde")
elif not str(payload.connection_name or "").strip():
raise HTTPException(status_code=400, detail="Skriv et navn til den nye forbindelse")
customer_name = str(customer.get("name") or f"Kunde #{payload.customer_id}")
product_name = str(product.get("name") or "Internet")
case_title = str(payload.connection_name or "").strip() or f"Internet - {customer_name}"
description = "\n".join(filter(None, [
f"Internetabonnement: {product_name}",
f"Forbindelse: {(existing_connection or {}).get('name') or case_title}",
f"Adresse: {str(payload.address or '').strip()}" if payload.address else None,
str(payload.notes or "").strip() or None,
]))
created_case = execute_query_single(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id)
VALUES (%s, %s, 'abonnement', 'åben', %s, %s, 1)
RETURNING *
""",
(case_title, description, customer_id, _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])),
)
if not created_case:
raise HTTPException(status_code=500, detail="Abonnementssagen kunne ikke oprettes")
resolved_price = float(payload.unit_price) if payload.unit_price is not None else float(product.get("sales_price") or 0)
from app.subscriptions.backend.router import create_subscription as create_sag_subscription
subscription = await create_sag_subscription({
"sag_id": int(created_case["id"]),
"billing_interval": payload.billing_interval,
"billing_day": int(payload.billing_day),
"start_date": payload.start_date.isoformat(),
"period_start": payload.start_date.isoformat(),
"notice_period_days": 30,
"line_items": [{
"product_id": int(product["id"]),
"description": str(product.get("short_description") or product_name),
"quantity": 1,
"unit_price": resolved_price,
}],
"notes": str(payload.notes or "").strip() or None,
})
connection_update = ConnectionUpdatePayload(
customer_id=customer_id,
subscription_id=int(subscription["id"]),
value_type="subscription",
value_label=None,
)
if existing_connection:
connection = await update_connection(int(existing_connection["id"]), connection_update)
else:
connection = await create_connection(ConnectionCreatePayload(
name=case_title,
vendor_id=payload.vendor_id,
customer_id=customer_id,
address=str(payload.address or "").strip() or None,
status="planned",
monthly_cost=float(payload.monthly_cost or 0),
sales_price=resolved_price,
technology=str(payload.technology or "").strip() or None,
circuit_number=str(payload.circuit_number or "").strip() or None,
download_mbps=payload.download_mbps,
upload_mbps=payload.upload_mbps,
contract_start=payload.start_date,
notes=str(payload.notes or "").strip() or None,
allocation_model="shared" if payload.shared_fiber else "dedicated",
value_type="delefiber" if payload.shared_fiber else "subscription",
subscription_id=int(subscription["id"]),
is_manual_shared=payload.shared_fiber,
))
return {"success": True, "sag": dict(created_case), "subscription": subscription, "connection": connection}
@router.post("/internet-connections/{connection_id}/bmcnet-connections")
async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
head_row = execute_query_single(

View File

@ -137,6 +137,33 @@
padding: 1rem;
}
.quick-customer-results {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 0 0 12px 12px;
background: var(--bg-card);
}
.quick-customer-result {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-primary);
text-align: left;
}
.quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); }
.quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; }
.quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; }
.quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); }
.quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); }
.quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; }
.invoice-sync-status {
display:inline-flex;
align-items:center;
@ -179,8 +206,8 @@
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
</button>
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="modal" data-bs-target="#quickSetupModal">
<i class="bi bi-plus-lg me-1"></i>Opret eller tilknyt
</button>
</div>
</div>
@ -388,6 +415,52 @@
</div>
</div>
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="quick-type-grid mb-3">
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
</div>
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
<div class="row g-3 d-none mt-1" id="quickSetupFields">
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
<div class="col-12" id="quickCustomerWrap">
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
<input type="hidden" id="quickCustomerId">
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
</div>
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
</div>
</div>
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
</div>
</div>
</div>
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
@ -442,6 +515,11 @@
let invoiceSyncItems = [];
let activeTab = 'all';
let subscriptionOptions = [];
let quickCustomerOptions = [];
let quickProductOptions = [];
let quickCustomerTimer = null;
let quickSetupType = null;
let quickSharedHeadOptions = [];
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
@ -844,6 +922,7 @@
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
const isOwnedSharedFiber = Boolean(item.is_shared_head && (item.value_type === 'delefiber' || item.is_manual_shared));
return `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td>
@ -854,11 +933,11 @@
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
</td>
<td>
<div>${escapeHtml(item.customer_name || '-')}</div>
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
${uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${item.sla_subscription_id
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div>
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}</div>
${!isOwnedSharedFiber && uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!isOwnedSharedFiber && !uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${isOwnedSharedFiber ? '' : item.sla_subscription_id
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
</td>
@ -1052,11 +1131,308 @@
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
const quickVendor = document.getElementById('quickVendor');
if (quickVendor) quickVendor.innerHTML = '<option value="">Vælg leverandør…</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
}
}
function quickCustomerLabel(item) {
return `${item.id} · ${item.name || '-'}`;
}
function resolveQuickCustomerId() {
const raw = document.getElementById('quickCustomerLookup').value.trim();
const normalized = raw.toLowerCase();
const match = quickCustomerOptions.find((item) =>
quickCustomerLabel(item).toLowerCase() === normalized
|| String(item.name || '').trim().toLowerCase() === normalized
|| String(item.id) === raw
);
const id = match ? Number(match.id) : null;
document.getElementById('quickCustomerId').value = id || '';
if (match && !document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${match.name}`;
}
if (match) {
const customerAddress = [match.address, match.postal_code, match.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
}
updateQuickSetupSummary();
return id;
}
async function searchQuickCustomers(query) {
const q = String(query || '').trim();
const results = document.getElementById('quickCustomerResults');
if (q.length < 2) {
quickCustomerOptions = [];
results.innerHTML = '';
results.classList.add('d-none');
return;
}
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);
if (document.getElementById('quickCustomerLookup').value.trim() !== q) return;
if (response.status === 401) {
quickCustomerOptions = [];
results.innerHTML = '<div class="p-3 text-danger"><strong>Din session er udløbet.</strong><br><a href="/login">Log ind igen</a> og åbn derefter guiden på ny.</div>';
results.classList.remove('d-none');
return;
}
if (!response.ok) {
quickCustomerOptions = [];
results.innerHTML = `<div class="p-3 text-danger">Kunderne kunne ikke hentes (fejl ${response.status}).</div>`;
results.classList.remove('d-none');
return;
}
quickCustomerOptions = response.ok ? await response.json() : [];
results.innerHTML = quickCustomerOptions.length
? quickCustomerOptions.map((item) => `
<button class="quick-customer-result" type="button" data-quick-customer-id="${item.id}">
<strong>${escapeHtml(item.name || '-')}</strong>
<span class="small text-muted ms-2">${item.cvr_nummer ? `CVR ${escapeHtml(item.cvr_nummer)}` : `ID ${item.id}`}</span>
${item.address ? `<div class="small text-muted">${escapeHtml([item.address, item.postal_code, item.city].filter(Boolean).join(', '))}</div>` : ''}
</button>`).join('')
: '<div class="small text-muted p-3">Ingen kunder fundet</div>';
results.classList.remove('d-none');
}
function selectQuickCustomer(customerId) {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(customerId));
if (!customer) return;
document.getElementById('quickCustomerLookup').value = customer.name || quickCustomerLabel(customer);
document.getElementById('quickCustomerId').value = String(customer.id);
document.getElementById('quickCustomerResults').classList.add('d-none');
if (!document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${customer.name}`;
}
const customerAddress = [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
updateQuickSetupSummary();
}
async function loadQuickProducts() {
const response = await fetch('/api/v1/products?limit=1000&is_active=true');
const products = response.ok ? await response.json() : [];
quickProductOptions = products.filter((item) => {
const text = `${item.name || ''} ${item.short_description || ''}`.toLowerCase();
const network = item.attributes_json?.network || {};
return network.kind === 'internet_access' || /internet|fiber|bredb/.test(text);
});
document.getElementById('quickProduct').innerHTML = '<option value="">Vælg internetprodukt…</option>'
+ quickProductOptions.map((item) => `<option value="${item.id}" data-price="${Number(item.sales_price || 0)}">${escapeHtml(item.name || '-')} · ${formatDKK(item.sales_price || 0)}</option>`).join('');
}
function selectQuickSetupType(type) {
quickSetupType = type;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.toggle('active', button.dataset.quickType === type));
document.getElementById('quickTypeHint').classList.add('d-none');
document.getElementById('quickSetupFields').classList.remove('d-none');
document.getElementById('quickSetupSubmit').classList.remove('d-none');
const isBmcnet = type === 'bmcnet';
const isShared = type === 'shared';
document.getElementById('quickSharedHeadWrap').classList.toggle('d-none', !isBmcnet);
document.getElementById('quickConnectionModeWrap').classList.toggle('d-none', isBmcnet || isShared);
document.getElementById('quickCustomerWrap').classList.toggle('d-none', isShared);
document.getElementById('quickOwnerWrap').classList.toggle('d-none', !isShared);
document.querySelectorAll('.quick-subscription-field').forEach((node) => node.classList.toggle('d-none', isShared));
document.getElementById('quickConnectionStepTitle').textContent = isShared ? '2. Forbindelse' : '3. Forbindelse';
document.getElementById('quickAdvancedConnectionFields').classList.toggle('d-none', isBmcnet);
document.getElementById('quickBmcnetNotesWrap').classList.toggle('d-none', !isBmcnet);
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', isBmcnet));
if (isBmcnet || isShared) document.getElementById('quickConnectionMode').value = 'new';
document.getElementById('quickSetupSubmit').innerHTML = isBmcnet
? '<i class="bi bi-check2-circle me-1"></i>Opret BMCnet og abonnement'
: isShared
? '<i class="bi bi-check2-circle me-1"></i>Opret delt hovedfiber'
: '<i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement';
toggleQuickConnectionMode();
}
function toggleQuickConnectionMode() {
const existing = document.getElementById('quickConnectionMode').value === 'existing';
document.getElementById('quickExistingWrap').classList.toggle('d-none', !existing);
document.querySelectorAll('.quick-new-field').forEach((node) => node.classList.toggle('d-none', existing));
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', existing || quickSetupType === 'bmcnet'));
updateQuickSetupSummary();
}
async function populateQuickConnections() {
let connections = [];
try {
const response = await fetch('/api/v1/internet-connections');
connections = response.ok ? await response.json() : [];
} catch (error) {
connections = allConnections;
}
document.getElementById('quickExistingConnection').innerHTML = '<option value="">Vælg forbindelse…</option>'
+ connections.filter((item) => !item.subscription_id).map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)} · ${escapeHtml(item.customer_name || 'Ingen kunde')}</option>`).join('');
quickSharedHeadOptions = connections.filter((item) => item.is_shared_head);
document.getElementById('quickSharedHead').innerHTML = '<option value="">Vælg hovedforbindelse…</option>'
+ quickSharedHeadOptions.map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)}${item.address ? ` · ${escapeHtml(item.address)}` : ''}</option>`).join('');
}
function normalizeQuickAddress(value) {
return String(value || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function quickEditDistance(left, right) {
const previous = Array.from({length: right.length + 1}, (_, index) => index);
for (let i = 1; i <= left.length; i += 1) {
let diagonal = previous[0];
previous[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const above = previous[j];
previous[j] = Math.min(
previous[j] + 1,
previous[j - 1] + 1,
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
);
diagonal = above;
}
}
return previous[right.length];
}
function quickTokenSimilarity(left, right) {
if (left === right) return 1;
return 1 - (quickEditDistance(left, right) / Math.max(left.length, right.length, 1));
}
function quickAddressMatchScore(input, candidate) {
const wanted = normalizeQuickAddress(input);
const existing = normalizeQuickAddress(candidate);
if (!wanted || !existing) return 0;
if (wanted === existing) return 100;
const wantedParts = wanted.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const existingParts = existing.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const tokenScore = wantedParts.length
? wantedParts.reduce((sum, part) => sum + Math.max(0, ...existingParts.map((candidatePart) => quickTokenSimilarity(part, candidatePart))), 0) / wantedParts.length
: 0;
const wantedNumbers = wanted.match(/\b\d+[a-z]?\b/g) || [];
const existingNumbers = new Set(existing.match(/\b\d+[a-z]?\b/g) || []);
const wantedPostal = wantedNumbers.find((part) => /^\d{4}$/.test(part));
const wantedHouse = wantedNumbers.find((part) => part !== wantedPostal);
const samePostal = Boolean(wantedPostal && existingNumbers.has(wantedPostal));
const sameHouseNumber = Boolean(wantedHouse && existingNumbers.has(wantedHouse));
return Math.round((tokenScore * 65) + (sameHouseNumber ? 25 : 0) + (samePostal ? 10 : 0));
}
function suggestQuickSharedHead() {
if (quickSetupType !== 'bmcnet') return;
const address = document.getElementById('quickAddress').value.trim();
const select = document.getElementById('quickSharedHead');
const hint = document.getElementById('quickSharedHeadSuggestion');
if (address.length < 4) {
hint.className = 'small text-muted mt-1';
hint.textContent = 'Skriv installationsadressen, så foreslår systemet en hovedforbindelse.';
return;
}
const matches = quickSharedHeadOptions
.map((item) => ({item, score: quickAddressMatchScore(address, item.address)}))
.filter((match) => match.score >= 55)
.sort((a, b) => b.score - a.score);
if (!matches.length) {
hint.className = 'small text-warning mt-1';
hint.textContent = 'Ingen delt hovedforbindelse matcher adressen. Vælg manuelt.';
return;
}
const best = matches[0];
select.value = String(best.item.id);
hint.className = 'small text-success fw-semibold mt-1';
hint.innerHTML = `<i class="bi bi-check-circle me-1"></i>Foreslået ud fra adressen: ${escapeHtml(best.item.name || `#${best.item.id}`)}${best.item.address ? ` · ${escapeHtml(best.item.address)}` : ''}`;
updateQuickSetupSummary();
}
function updateQuickSetupSummary() {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(document.getElementById('quickCustomerId').value || 0));
const product = quickProductOptions.find((item) => Number(item.id) === Number(document.getElementById('quickProduct').value || 0));
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const connectionText = existing
? document.getElementById('quickExistingConnection').selectedOptions[0]?.textContent || 'vælg forbindelse'
: document.getElementById('quickConnectionName').value.trim() || 'ny forbindelse';
const owner = quickSetupType === 'shared' ? 'BMC Networks (delefiber)' : (customer?.name || 'vælg kunde');
document.getElementById('quickSetupSummary').innerHTML = quickSetupType === 'shared'
? `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(connectionText)}. Der oprettes kun en delt hovedforbindelse — intet produkt eller abonnement.`
: `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(product?.name || 'vælg produkt')} · ${escapeHtml(connectionText)}. Abonnementet oprettes som kladde.`;
}
async function submitQuickSetup() {
const feedback = document.getElementById('quickSetupFeedback');
const button = document.getElementById('quickSetupSubmit');
const customerId = resolveQuickCustomerId();
const productId = Number(document.getElementById('quickProduct').value || 0) || null;
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const payload = {
customer_id: customerId,
product_id: productId,
start_date: document.getElementById('quickStartDate').value,
connection_id: existing ? (Number(document.getElementById('quickExistingConnection').value || 0) || null) : null,
connection_name: existing ? null : document.getElementById('quickConnectionName').value.trim(),
vendor_id: existing ? null : (Number(document.getElementById('quickVendor').value || 0) || null),
circuit_number: document.getElementById('quickCircuit').value.trim() || null,
address: document.getElementById('quickAddress').value.trim() || null,
technology: document.getElementById('quickTechnology').value.trim() || null,
download_mbps: Number(document.getElementById('quickDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('quickUpload').value || 0) || null,
monthly_cost: Number(document.getElementById('quickCost').value || 0),
unit_price: document.getElementById('quickPrice').value === '' ? null : Number(document.getElementById('quickPrice').value),
billing_interval: document.getElementById('quickInterval').value,
billing_day: Number(document.getElementById('quickBillingDay').value || 1),
notes: (quickSetupType === 'bmcnet' ? document.getElementById('quickBmcnetNotes').value : document.getElementById('quickNotes').value).trim() || null,
shared_fiber: quickSetupType === 'shared',
};
if (!quickSetupType) {
feedback.textContent = 'Vælg først fysisk forbindelse eller BMCnet.';
return;
}
const missingSubscription = quickSetupType !== 'shared' && (!payload.customer_id || !payload.product_id || !payload.start_date);
if (missingSubscription || (existing ? !payload.connection_id : !payload.connection_name)) {
feedback.textContent = quickSetupType === 'shared'
? 'Skriv et navn til hovedforbindelsen.'
: 'Vælg kunde, produkt, startdato og forbindelse.';
return;
}
button.disabled = true;
feedback.textContent = 'Opretter og forbinder…';
try {
const sharedHeadId = Number(document.getElementById('quickSharedHead').value || 0) || null;
if (quickSetupType === 'bmcnet' && !sharedHeadId) throw new Error('Vælg en delt hovedforbindelse.');
const endpoint = quickSetupType === 'bmcnet'
? `/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`
: '/api/v1/internet-connections/quick-setup';
const requestPayload = quickSetupType === 'bmcnet' ? {
customer_id: payload.customer_id,
case_title: payload.connection_name,
service_label: payload.connection_name,
address: payload.address,
billing_interval: payload.billing_interval,
billing_day: payload.billing_day,
start_date: payload.start_date,
internet_product_id: payload.product_id,
internet_unit_price: payload.unit_price,
notes: payload.notes,
} : payload;
const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestPayload)});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
const connectionLink = `<a href="/economy/internet-connections/${result.connection.id}">Åbn forbindelsen</a>`;
const subscriptionLink = result.sag?.id ? ` · <a href="/sag/${result.sag.id}/v3?tab=subscription">Åbn abonnementet</a>` : '';
feedback.innerHTML = `Oprettet. ${connectionLink}${subscriptionLink}`;
await loadInternetPage();
} catch (error) {
feedback.textContent = `Kunne ikke oprette: ${error.message}`;
} finally {
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
@ -1064,6 +1440,34 @@
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
document.getElementById('quickCustomerLookup').addEventListener('input', (event) => {
document.getElementById('quickCustomerId').value = '';
clearTimeout(quickCustomerTimer);
quickCustomerTimer = setTimeout(() => searchQuickCustomers(event.target.value), 220);
});
document.getElementById('quickCustomerLookup').addEventListener('change', resolveQuickCustomerId);
document.getElementById('quickCustomerResults').addEventListener('click', (event) => {
const button = event.target.closest('[data-quick-customer-id]');
if (button) selectQuickCustomer(Number(button.dataset.quickCustomerId));
});
document.getElementById('quickProduct').addEventListener('change', (event) => {
const option = event.target.selectedOptions[0];
document.getElementById('quickPrice').value = option?.value ? option.dataset.price || '' : '';
updateQuickSetupSummary();
});
document.getElementById('quickAddress').addEventListener('input', suggestQuickSharedHead);
['quickExistingConnection','quickConnectionName','quickStartDate','quickInterval','quickBillingDay'].forEach((id) => document.getElementById(id)?.addEventListener('change', updateQuickSetupSummary));
document.getElementById('quickSetupModal').addEventListener('show.bs.modal', async () => {
quickSetupType = null;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.remove('active'));
document.getElementById('quickTypeHint').classList.remove('d-none');
document.getElementById('quickSetupFields').classList.add('d-none');
document.getElementById('quickSetupSubmit').classList.add('d-none');
if (!document.getElementById('quickStartDate').value) document.getElementById('quickStartDate').value = new Date().toISOString().slice(0, 10);
await loadQuickProducts();
await populateQuickConnections();
toggleQuickConnectionMode();
});
toggleCreateValueFields();
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);

View File

@ -2,16 +2,77 @@ import json
import logging
from datetime import date
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import aiohttp
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.core.economic_write_policy import assert_economic_write_allowed
logger = logging.getLogger(__name__)
def _economic_error_message(status: int, response_text: str) -> str:
"""Return a useful, bounded e-conomic validation error without headers or credentials."""
raw = str(response_text or "").strip()
messages: List[str] = []
try:
payload = json.loads(raw)
except (TypeError, ValueError):
payload = None
def collect(value: Any, prefix: str = "") -> None:
if isinstance(value, dict):
for key, child in value.items():
if str(key).lower() in {"developerhint", "logid", "httpstatuscode"}:
continue
collect(child, f"{prefix}.{key}".strip("."))
elif isinstance(value, list):
for child in value:
collect(child, prefix)
elif value not in (None, "") and str(value) not in messages:
label = f"{prefix}: " if prefix else ""
messages.append(f"{label}{value}")
if payload is not None:
collect(payload)
elif raw:
messages.append(raw)
detail = " · ".join(messages)[:1200]
return f"e-conomic afviste ordren ({status})" + (f": {detail}" if detail else "")
def _require_valid_product_numbers(product_numbers: set[str], valid_product_numbers: set[str]) -> List[str]:
"""Reject priced lines before POST when e-conomic cannot resolve their product."""
missing = sorted(product_numbers - valid_product_numbers)
if missing:
raise HTTPException(
status_code=409,
detail=(
"Ordren blev ikke sendt. Følgende varenumre findes ikke i e-conomic: "
+ ", ".join(missing)
+ ". Knyt eller opret varerne under Varer og e-conomic, og prøv igen."
),
)
return missing
def _product_creation_payload(proposal: Dict[str, Any], group_number: int) -> Dict[str, Any]:
payload = {
"productNumber": proposal["product_number"],
"name": str(proposal["name"])[:300],
"description": str(proposal.get("description") or proposal["name"])[:2500],
"salesPrice": proposal["sales_price"],
"barred": False,
"productGroup": {"productGroupNumber": group_number},
}
if proposal.get("ean"):
payload["barCode"] = str(proposal["ean"])[:50]
return payload
class OrdreEconomicExportService:
"""e-conomic export service for global ordre page."""
@ -20,8 +81,17 @@ class OrdreEconomicExportService:
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN
self.read_only = settings.ORDRE_ECONOMIC_READ_ONLY
self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN
configured_fields = getattr(settings, "model_fields_set", set())
self.read_only = (
settings.ORDRE_ECONOMIC_READ_ONLY
if "ORDRE_ECONOMIC_READ_ONLY" in configured_fields
else settings.ECONOMIC_READ_ONLY
)
self.dry_run = (
settings.ORDRE_ECONOMIC_DRY_RUN
if "ORDRE_ECONOMIC_DRY_RUN" in configured_fields
else settings.ECONOMIC_DRY_RUN
)
self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT
self.default_product = settings.ORDRE_ECONOMIC_PRODUCT
@ -57,7 +127,21 @@ class OrdreEconomicExportService:
notes: Optional[str] = None,
layout_number: Optional[int] = None,
user_id: Optional[int] = None,
document_key: Optional[str] = None,
currency: str = 'DKK',
create_missing_products: Optional[Dict[str, int]] = None,
export_reference: Optional[str] = None,
external_id: Optional[int] = None,
) -> Dict[str, Any]:
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
connection = active_connection()
if connection:
if not self._check_write_permission(f'Export ordre for customer {customer_id}') or settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
checked = await preflight(connection, customer_id, lines, layout_number, currency, notes)
return {'success': True, 'dry_run': True, 'message': 'Safety mode: valideret uden ekstern skrivning', 'details': checked}
return await export_document(connection, 'order', document_key or unsaved_key(customer_id, lines, notes, layout_number),
customer_id, lines, layout_number, currency, notes, user_id,
external_id=external_id)
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
@ -81,7 +165,9 @@ class OrdreEconomicExportService:
product_map: Dict[int, str] = {}
if product_ids:
product_rows = execute_query(
"SELECT id, sku_internal FROM products WHERE id = ANY(%s)",
"""SELECT id, name, sku_internal, ean, short_description, long_description,
sales_price, economic_product_group_number
FROM products WHERE id = ANY(%s)""",
(product_ids,),
) or []
product_map = {
@ -89,8 +175,13 @@ class OrdreEconomicExportService:
for row in product_rows
if row.get("sku_internal")
}
product_details = {int(row["id"]): row for row in product_rows}
else:
product_details = {}
economic_lines: List[Dict[str, Any]] = []
creation_candidates: Dict[str, Dict[str, Any]] = {}
created_product_numbers: List[str] = []
for line in selected_lines:
try:
quantity = float(line.get("quantity") or 0)
@ -123,29 +214,150 @@ class OrdreEconomicExportService:
if product_number:
line_payload["product"] = {"productNumber": str(product_number)}
local_product = product_details.get(int(product_id)) if product_id is not None and str(product_id).isdigit() else None
creation_candidates.setdefault(str(product_number), {
"product_number": str(product_number),
"product_id": int(product_id) if product_id is not None and str(product_id).isdigit() else None,
"name": (local_product or {}).get("name") or line_payload["description"],
"description": (local_product or {}).get("long_description") or (local_product or {}).get("short_description") or line_payload["description"],
"ean": (local_product or {}).get("ean") or line.get("ean"),
"sales_price": float((local_product or {}).get("sales_price") or unit_price),
"suggested_group_number": (local_product or {}).get("economic_product_group_number"),
})
if discount > 0:
line_payload["discountPercentage"] = discount
economic_lines.append(line_payload)
operation = f"Export ordre for customer {customer_id} to e-conomic"
write_allowed = self._check_write_permission(operation)
async with aiohttp.ClientSession() as session:
customer_number = int(customer["economic_customer_number"])
async with session.get(
f"{self.api_url}/customers/{customer_number}",
headers=self._headers(),
timeout=aiohttp.ClientTimeout(total=30),
) as customer_response:
customer_text = await customer_response.text()
if customer_response.status != 200:
logger.error("❌ e-conomic customer lookup failed (%s): %s", customer_response.status, customer_text)
raise HTTPException(
status_code=502,
detail=_economic_error_message(customer_response.status, customer_text),
)
economic_customer = await customer_response.json(content_type=None)
payment_terms = economic_customer.get("paymentTerms")
vat_zone = economic_customer.get("vatZone")
if not payment_terms or not vat_zone:
raise HTTPException(
status_code=502,
detail="e-conomic-kunden mangler betalingsbetingelser eller momszone",
)
product_numbers = {
str(line.get("product", {}).get("productNumber") or "").strip()
for line in economic_lines
if line.get("product")
}
valid_product_numbers = set()
for product_number in product_numbers:
if not product_number:
continue
async with session.get(
f"{self.api_url}/products/{quote(product_number, safe='')}",
headers=self._headers(),
timeout=aiohttp.ClientTimeout(total=30),
) as product_response:
if product_response.status == 200:
valid_product_numbers.add(product_number)
elif product_response.status == 404:
logger.warning("e-conomic product %s does not exist", product_number)
else:
product_text = await product_response.text()
raise HTTPException(
status_code=502,
detail=_economic_error_message(product_response.status, product_text),
)
missing_product_numbers = sorted(product_numbers - valid_product_numbers)
if missing_product_numbers:
async with session.get(
f"{self.api_url}/product-groups?pagesize=1000",
headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30),
) as groups_response:
groups_text = await groups_response.text()
if groups_response.status != 200:
raise HTTPException(502, _economic_error_message(groups_response.status, groups_text))
groups_data = await groups_response.json(content_type=None)
groups = [
{"number": row.get("productGroupNumber"), "name": row.get("name") or ""}
for row in (groups_data.get("collection") or []) if row.get("productGroupNumber") is not None
]
approved_groups = create_missing_products or {}
if any(number not in approved_groups for number in missing_product_numbers):
raise HTTPException(status_code=409, detail={
"code": "economic_products_missing",
"message": "Godkend oprettelse af de manglende varer før ordren eksporteres.",
"products": [creation_candidates[number] for number in missing_product_numbers],
"product_groups": groups,
})
if self.read_only or self.dry_run:
raise HTTPException(409, "Safety mode blokerer oprettelse af varer i e-conomic")
available_groups = {int(group["number"]) for group in groups}
for number in missing_product_numbers:
group_number = int(approved_groups[number])
if group_number not in available_groups:
raise HTTPException(409, f"Varegruppe {group_number} findes ikke i e-conomic")
proposal = creation_candidates[number]
product_payload = _product_creation_payload(proposal, group_number)
assert_economic_write_allowed("POST", "/products")
async with session.post(
f"{self.api_url}/products", headers=self._headers(), json=product_payload,
timeout=aiohttp.ClientTimeout(total=30),
) as create_response:
create_text = await create_response.text()
if create_response.status not in (200, 201):
raise HTTPException(502, _economic_error_message(create_response.status, create_text))
created = await create_response.json(content_type=None)
if str(created.get("productNumber") or "") != number:
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
valid_product_numbers.add(number)
created_product_numbers.append(number)
_require_valid_product_numbers(product_numbers, valid_product_numbers)
customer_layout = economic_customer.get("layout") or {}
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout
payload: Dict[str, Any] = {
"date": date.today().isoformat(),
"currency": "DKK",
"customer": {
"customerNumber": int(customer["economic_customer_number"]),
"currency": str(economic_customer.get("currency") or "DKK"),
"customer": {"customerNumber": customer_number},
"paymentTerms": payment_terms,
"recipient": {
"name": str(economic_customer.get("name") or customer.get("name") or "Kunde"),
"address": str(economic_customer.get("address") or ""),
"zip": str(economic_customer.get("zip") or ""),
"city": str(economic_customer.get("city") or ""),
"country": str(economic_customer.get("country") or ""),
"vatZone": vat_zone,
},
"layout": {
"layoutNumber": int(layout_number or self.default_layout),
"layoutNumber": int(resolved_layout_number),
},
"lines": economic_lines,
}
if notes:
payload["notes"] = {"textLine1": str(notes)[:250]}
payload["notes"] = {"textLine1": str(notes)[:1000]}
if external_id is not None:
payload["references"] = {"other": str(external_id)[:100]}
elif export_reference:
payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]}
operation = f"Export ordre for customer {customer_id} to e-conomic"
if not self._check_write_permission(operation):
if not write_allowed:
return {
"success": True,
"dry_run": True,
@ -157,13 +369,15 @@ class OrdreEconomicExportService:
"read_only": self.read_only,
"dry_run": self.dry_run,
"user_id": user_id,
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"payload": payload,
},
}
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
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._headers(),
@ -175,7 +389,7 @@ class OrdreEconomicExportService:
logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text)
raise HTTPException(
status_code=502,
detail=f"e-conomic export fejlede ({response.status})",
detail=_economic_error_message(response.status, response_text),
)
export_result = await response.json(content_type=None)
@ -192,7 +406,15 @@ class OrdreEconomicExportService:
"customer_name": customer.get("name"),
"selected_line_count": len(selected_lines),
"user_id": user_id,
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"economic_response": export_result,
"request_payload": payload,
"economic_customer_number": customer_number,
"currency": payload["currency"],
"layout_number": int(resolved_layout_number),
"economic_order_url": export_result.get("self"),
"created_product_numbers": created_product_numbers,
},
}

View File

@ -0,0 +1,182 @@
"""Safe updates and daily read-only lifecycle checks for e-conomic order drafts."""
import json
import logging
from copy import deepcopy
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from uuid import UUID
from fastapi import HTTPException
from psycopg2.extras import Json
from app.core.economic_write_policy import approved_order_draft_update
from app.products.backend.economic_catalog import EconomicClient, RemoteError, query, transaction
from app.products.backend.economic_documents import active_connection
logger = logging.getLogger(__name__)
def _number(value):
try:
return str(Decimal(str(value or 0)).quantize(Decimal('.01')))
except Exception:
return '0.00'
def _editable_snapshot(payload):
return {
'notes': ((payload.get('notes') or {}).get('textLine1') or ''),
'lines': [{
'product_number': ((line.get('product') or {}).get('productNumber')),
'description': line.get('description') or '',
'quantity': _number(line.get('quantity')),
'unit_price': _number(line.get('unitNetPrice')),
'discount_percentage': _number(line.get('discountPercentage')),
} for line in payload.get('lines') or []],
}
def build_update_payload(remote, local_lines, notes):
"""Keep external identity fields and only replace user-editable order content."""
allowed = ('date', 'currency', 'customer', 'paymentTerms', 'recipient', 'layout', 'references')
payload = {key: deepcopy(remote[key]) for key in allowed if key in remote}
remote_lines = remote.get('lines') or []
if len(remote_lines) != len(local_lines):
raise HTTPException(409, 'Antallet af linjer er ændret. Opret en ny ordre for at tilføje eller fjerne varer.')
changed, payload_lines = [], []
for remote_line, local in zip(remote_lines, local_lines):
if not (remote_line.get('product') or {}).get('productNumber'):
raise HTTPException(409, 'En eksisterende e-conomic-linje mangler varenummer og kan ikke opdateres sikkert.')
output = {'product': deepcopy(remote_line['product']),
'description': str(local.get('description') or 'Ordrelinje'),
'quantity': float(local.get('quantity') or 0),
'unitNetPrice': float(local.get('unit_price') or 0),
'discountPercentage': float(local.get('discount_percentage') or 0)}
if remote_line.get('unit'):
output['unit'] = deepcopy(remote_line['unit'])
if output['quantity'] <= 0 or output['unitNetPrice'] < 0 or not 0 <= output['discountPercentage'] <= 100:
raise HTTPException(422, 'Ugyldig mængde, pris eller rabat')
payload_lines.append(output)
changed.append(dict(local))
payload['lines'] = payload_lines
if notes:
payload['notes'] = {'textLine1': str(notes)[:1000]}
else:
payload.pop('notes', None)
return payload, changed
async def create_update_preview(draft_id, local_lines, notes, actor_id):
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
if not row:
raise HTTPException(404, 'Ordren findes ikke')
if not row.get('economic_order_number') or row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
raise HTTPException(409, 'Kun en åben e-conomic-ordrekladde kan opdateres')
connection = active_connection()
client = EconomicClient()
if connection:
await client.verify(connection['id'])
try:
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
except RemoteError as exc:
if exc.status == 404:
raise HTTPException(409, 'Ordren er ikke længere en åben kladde. Hent status for at finde fakturaen.') from exc
raise HTTPException(502, str(exc)) from exc
proposed, saved_lines = build_update_payload(remote, local_lines, notes)
before, after = _editable_snapshot(remote), _editable_snapshot(proposed)
with transaction() as cur:
cur.execute("UPDATE economic_order_update_requests SET status='expired' WHERE draft_id=%s AND status='pending'", (draft_id,))
cur.execute('''INSERT INTO economic_order_update_requests
(draft_id,economic_order_number,before_snapshot,proposed_payload,proposed_local_lines,requested_by_user_id)
VALUES(%s,%s,%s,%s,%s,%s) RETURNING id,expires_at''',
(draft_id, str(row['economic_order_number']), Json(before), Json(proposed), Json(saved_lines), actor_id))
request_row = cur.fetchone()
return {'request_id': str(request_row['id']), 'expires_at': request_row['expires_at'], 'before': before, 'after': after}
async def apply_update(request_id, draft_id, actor_id):
try:
UUID(str(request_id))
except ValueError as exc:
raise HTTPException(422, 'Ugyldig godkendelsesreference') from exc
row = query('''SELECT r.*,d.sync_status,d.economic_invoice_number FROM economic_order_update_requests r
JOIN ordre_drafts d ON d.id=r.draft_id WHERE r.id=%s AND r.draft_id=%s''', (request_id, draft_id), one=True)
if not row or row['status'] != 'pending' or row['expires_at'] <= datetime.now(timezone.utc):
raise HTTPException(409, 'Godkendelsen er udløbet eller allerede brugt')
if row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
raise HTTPException(409, 'Ordren er faktureret og kan ikke længere opdateres')
client = EconomicClient()
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
if _editable_snapshot(remote) != row['before_snapshot']:
raise HTTPException(409, 'Ordren er ændret i e-conomic siden forhåndsvisningen. Lav en ny forhåndsvisning.')
try:
with approved_order_draft_update():
response = await client.request('PUT', f"orders/drafts/{int(row['economic_order_number'])}", row['proposed_payload'])
except RemoteError as exc:
query("UPDATE economic_order_update_requests SET status='failed',error=%s WHERE id=%s", (str(exc), request_id))
raise HTTPException(502, str(exc)) from exc
with transaction() as cur:
cur.execute("SELECT set_config('bmc.economic_order_update',%s,true)", (str(draft_id),))
cur.execute('''UPDATE ordre_drafts SET lines_json=%s,notes=%s,export_request_json=%s,
export_response_json=%s,last_sync_at=now(),updated_at=now() WHERE id=%s''',
(Json(row['proposed_local_lines']), (row['proposed_payload'].get('notes') or {}).get('textLine1'),
Json(row['proposed_payload']), Json(response), draft_id))
cur.execute("UPDATE economic_order_update_requests SET status='applied',applied_at=now(),response=%s WHERE id=%s", (Json(response), request_id))
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload,created_by_user_id)
VALUES(%s,'economic_draft_updated',%s,%s,%s,%s)''',
(draft_id, row['sync_status'], row['sync_status'], Json({'request_id': str(request_id)}), actor_id))
return {'status': 'updated', 'economic_order_number': row['economic_order_number']}
async def sync_one_draft(draft_id, force=False):
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
if not row or not row.get('economic_order_number'):
raise HTTPException(404, 'Ordren har intet e-conomic-ordrenummer')
if not force and row.get('economic_last_checked_at') and row['economic_last_checked_at'] > datetime.now(timezone.utc) - timedelta(days=1):
return {'status': row.get('economic_document_state') or 'unchanged', 'skipped': True}
client = EconomicClient()
marker = 'BMC-HUB:' + str(row.get('economic_export_id') or row.get('export_idempotency_key') or '')
state, invoice, remote = 'draft', None, None
try:
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
except RemoteError as exc:
if exc.status != 404:
query('UPDATE ordre_drafts SET economic_last_checked_at=now(),economic_status_error=%s WHERE id=%s', (str(exc), draft_id))
raise
booked = await client.collection('invoices/booked')
order_number = str(row['economic_order_number'])
matches = [x for x in booked if (
(x.get('references') or {}).get('other') == marker
or str(x.get('orderNumber') or (x.get('references') or {}).get('orderNumber') or '') == order_number
)]
if len(matches) == 1:
remote, invoice, state = matches[0], str(matches[0].get('bookedInvoiceNumber') or matches[0].get('invoiceNumber')), 'posted'
paid = await client.collection('invoices/paid')
if any(str(x.get('bookedInvoiceNumber') or x.get('invoiceNumber')) == invoice for x in paid):
state = 'paid'
else:
state = 'not_found'
payment = 'paid' if state == 'paid' else ('unpaid' if state == 'posted' else None)
with transaction() as cur:
cur.execute('''UPDATE ordre_drafts SET economic_invoice_number=COALESCE(%s,economic_invoice_number),
sync_status=CASE WHEN %s='paid' THEN 'paid' WHEN %s='posted' THEN 'posted' ELSE sync_status END,
economic_document_state=%s,economic_payment_status=%s,economic_status_response=%s,
economic_status_error=NULL,economic_last_checked_at=now(),last_sync_at=now(),updated_at=now() WHERE id=%s''',
(invoice,state,state,state,payment,Json(remote or {}),draft_id))
if state in ('posted','paid') and state != row.get('sync_status'):
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload)
VALUES(%s,'economic_status_sync',%s,%s,%s)''', (draft_id,row.get('sync_status'),state,Json({'invoice_number':invoice,'payment_status':payment})))
return {'status': state, 'invoice_number': invoice, 'payment_status': payment, 'skipped': False}
async def sync_due_drafts():
rows = query("""SELECT id FROM ordre_drafts WHERE economic_order_number IS NOT NULL
AND sync_status IN ('exported','posted')
AND (economic_last_checked_at IS NULL OR economic_last_checked_at < now()-interval '1 day')
ORDER BY economic_last_checked_at NULLS FIRST LIMIT 100""") or []
for row in rows:
try:
await sync_one_draft(row['id'])
except Exception:
logger.exception('Daglig e-conomic-statuskontrol fejlede for ordre %s', row['id'])
return {'checked': len(rows)}

View File

@ -12,7 +12,42 @@ from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"}
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
"""Normalize legacy and catalog-aware e-conomic export response fields."""
return (
export_result.get("economic_order_number")
or export_result.get("economic_draft_id")
or export_result.get("draftOrderNumber")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
def _snapshot_lines(lines):
from app.products.backend.economic_documents import active_connection, snapshot_draft_lines
return snapshot_draft_lines(lines) if active_connection() else lines
async def _check_draft_prices(http_request, customer_id, lines, draft_id=None):
from app.products.backend.economic_documents import active_connection
if not active_connection():
return
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
from app.products.backend.economic_pricing import validate_manual_prices
from app.core.database import execute_query_single
actor = await get_current_user(http_request, await security(http_request))
saved = execute_query_single('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (draft_id,)) if draft_id else None
saved_lines = _safe_json_field(saved['lines_json']) if saved and saved['customer_id'] == customer_id else []
validate_manual_prices(lines, customer_id, actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], 'economic.pricing.override'), saved_lines)
class OrdreLineInput(BaseModel):
@ -25,7 +60,9 @@ class OrdreLineInput(BaseModel):
discount_percentage: float = Field(default=0, ge=0, le=100)
unit: Optional[str] = None
product_id: Optional[int] = None
ean: Optional[str] = None
selected: bool = True
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
class OrdreExportRequest(BaseModel):
@ -35,6 +72,8 @@ class OrdreExportRequest(BaseModel):
layout_number: Optional[int] = None
draft_id: Optional[int] = None
force_export: bool = False
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
create_missing_products: Dict[str, int] = Field(default_factory=dict)
class OrdreDraftUpsertRequest(BaseModel):
@ -49,6 +88,15 @@ class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2)
class EconomicDraftUpdatePreviewRequest(BaseModel):
lines: List[Dict[str, Any]]
notes: Optional[str] = None
class EconomicDraftUpdateConfirmRequest(BaseModel):
request_id: str
def _safe_json_field(value: Any) -> Any:
if value is None:
return None
@ -138,6 +186,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"""Export selected ordre lines to e-conomic draft order."""
try:
user_id = _get_user_id_from_request(http_request)
from app.products.backend.economic_documents import active_connection
if active_connection() or request.create_missing_products:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'):
raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
if request.create_missing_products and not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.catalog.manage'):
raise HTTPException(403, 'Du mangler rettighed til at oprette varer i e-conomic')
user_id = actor['id']
previous_status = None
export_idempotency_key = None
@ -173,12 +231,18 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
)
line_payload = [line.model_dump() for line in request.lines]
await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id)
export_result = await ordre_economic_export_service.export_order(
customer_id=request.customer_id,
lines=line_payload,
notes=request.notes,
layout_number=request.layout_number,
user_id=user_id,
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
currency=request.currency,
create_missing_products=request.create_missing_products,
export_reference=export_idempotency_key,
external_id=request.draft_id,
)
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
@ -199,16 +263,22 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"timestamp": datetime.utcnow().isoformat(),
}
economic_order_number = (
export_result.get("economic_order_number")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
economic_order_number = _economic_order_number(export_result)
economic_invoice_number = (
export_result.get("economic_invoice_number")
or export_result.get("invoice_number")
or export_result.get("invoiceNumber")
)
if not export_result.get("dry_run") and not economic_order_number:
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
details = export_result.get("details") or {}
request_payload = details.get("request_payload") or {}
response_payload = details.get("economic_response") or {}
net_amount = sum(
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
* (1 - float(line.get("discountPercentage") or 0) / 100)
for line in (request_payload.get("lines") or [])
)
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
execute_query(
@ -219,6 +289,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key = %s,
economic_order_number = COALESCE(%s, economic_order_number),
economic_invoice_number = COALESCE(%s, economic_invoice_number),
economic_export_id = %s,
economic_order_url = %s,
exported_economic_customer_number = %s,
exported_currency = %s,
exported_layout_number = %s,
exported_net_amount = %s,
export_request_json = %s::jsonb,
export_response_json = %s::jsonb,
exported_created_products = %s::jsonb,
last_export_error = NULL,
exported_by_user_id = %s,
last_sync_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
@ -230,6 +311,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key,
str(economic_order_number) if economic_order_number is not None else None,
str(economic_invoice_number) if economic_invoice_number is not None else None,
export_result.get("export_id") or export_idempotency_key,
details.get("economic_order_url"),
str(details.get("economic_customer_number") or "") or None,
details.get("currency"),
details.get("layout_number"),
net_amount,
json.dumps(request_payload, ensure_ascii=False),
json.dumps(response_payload, ensure_ascii=False),
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
user_id,
request.draft_id,
),
)
@ -244,12 +335,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"idempotency_key": export_idempotency_key,
"economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_number,
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
"economic_customer_number": details.get("economic_customer_number"),
"currency": details.get("currency"),
"layout_number": details.get("layout_number"),
"net_amount": net_amount,
"created_product_numbers": details.get("created_product_numbers") or [],
},
user_id,
)
return export_result
except HTTPException:
except HTTPException as exc:
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
if request.draft_id and not is_preview:
try:
from app.core.database import execute_query
execute_query(
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
(str(exc.detail)[:2000], request.draft_id),
)
_log_sync_event(
request.draft_id, "export_failed", previous_status, previous_status,
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
"idempotency_key": export_idempotency_key}, user_id,
)
except Exception as log_exc:
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
raise
except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
@ -300,7 +412,13 @@ async def list_ordre_drafts(
async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering)."""
try:
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
query = """SELECT d.*, c.name AS customer_name,
c.economic_customer_number AS current_economic_customer_number,
u.full_name AS exported_by_name
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
WHERE d.id = %s LIMIT 1"""
params = (draft_id,)
from app.core.database import execute_query_single
@ -318,10 +436,41 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.orders.update'):
raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder')
return actor
@router.post('/ordre/drafts/{draft_id}/economic-update-preview')
async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import create_update_preview
return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-update-confirm')
async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import apply_update
return await apply_update(payload.request_id, draft_id, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-status-refresh')
async def refresh_economic_draft_status(draft_id: int, http_request: Request):
await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import sync_one_draft
return await sync_one_draft(draft_id, force=True)
@router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft."""
try:
await _check_draft_prices(http_request, request.customer_id, request.lines)
user_id = _get_user_id_from_request(http_request)
from app.core.database import execute_query
@ -342,7 +491,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
params = (
request.title,
request.customer_id,
json.dumps(request.lines, ensure_ascii=False),
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
request.notes,
request.layout_number,
user_id,
@ -350,6 +499,8 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
)
result = execute_query(query, params)
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create ordre draft")
@ -364,6 +515,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
query = """
SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count,
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
@ -373,6 +525,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
"""
return execute_query_single(query, ()) or {
"pending_count": 0,
"uncertain_count": 0,
"exported_count": 0,
"failed_count": 0,
"posted_count": 0,
@ -417,12 +570,13 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
from app.core.database import execute_query_single
previous = execute_query_single(
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s",
(draft_id,)
)
if not previous:
raise HTTPException(status_code=404, detail="Draft not found")
from_status = (previous.get("sync_status") or "pending").strip().lower()
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
values.append(draft_id)
from app.core.database import execute_query
@ -525,6 +679,20 @@ async def list_ordre_draft_sync_events(
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft."""
try:
from app.core.database import execute_query_single
existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,))
if not existing:
raise HTTPException(status_code=404, detail="Draft not found")
if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}:
content_changed = (
existing.get('customer_id') != request.customer_id
or (_safe_json_field(existing.get('lines_json')) or []) != request.lines
or (existing.get('notes') or None) != (request.notes or None)
or existing.get('layout_number') != request.layout_number
)
if content_changed:
raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.')
await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
from app.core.database import execute_query
query = """
@ -541,7 +709,7 @@ async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, ht
params = (
request.title,
request.customer_id,
json.dumps(request.lines, ensure_ascii=False),
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
request.notes,
request.layout_number,
draft_id,

View File

@ -344,9 +344,9 @@
const index = line.originalIndex;
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
onchange="ordreLines[${index}].description = this.value;">`
: escapeHtml(line.description || '-');
? `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
const manualActions = isManual
? `

View File

@ -88,6 +88,8 @@
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button>
<button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
<button class="btn btn-outline-primary" onclick="previewPrices()">Kontrollér nye priser</button>
<a class="btn btn-outline-primary" href="/settings/economic-products">Varekoblinger og eksportfejl</a>
</div>
</div>
@ -95,8 +97,9 @@
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
<div class="d-flex justify-content-end mb-3">
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
@ -115,6 +118,7 @@
<div class="info-item">
<div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</div>
</div>
<div class="col-md-3">
@ -145,13 +149,37 @@
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div>
<div id="exportReceiptCard" class="card mb-3 d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
</div></div>
</div>
<div class="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
</div>
<div class="d-flex gap-2">
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
</button>
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button>
@ -166,6 +194,7 @@
<div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option>
<option value="uncertain">uncertain</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
@ -176,7 +205,7 @@
<div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div>
<div class="col-md-3">
<div class="col-md-3" id="invoiceNumberEditWrap">
<div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div>
@ -200,15 +229,19 @@
<div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="col-md-3" id="invoiceNumberViewWrap">
<div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<label class="form-label mb-1">Event type</label>
<label class="form-label mb-1">Hændelse</label>
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div>
<div class="col-md-2">
@ -292,6 +325,37 @@
</div>
</div>
</div>
<div class="modal fade" id="economicProductPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Varer oprettes før eksport</h5>
<div class="text-muted small">Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>Der skrives ikke noget til e-conomic, før du vælger <strong>Opret varer og eksportér</strong>.</div>
<div id="economicProductPreviewRows"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="confirmEconomicProductsBtn" onclick="confirmEconomicProductCreation()">
<i class="bi bi-box-arrow-up-right me-1"></i>Opret varer og eksportér
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="economicUpdatePreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><div><h5 class="modal-title">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
</div></div>
</div>
</div>
{% endblock %}
@ -304,6 +368,8 @@
let syncEventsOffset = 0;
let syncEventsTotal = 0;
let detailToast = null;
let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast');
@ -322,8 +388,8 @@
detailToast.show();
}
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
function formatCurrency(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
}
function formatDate(dateStr) {
@ -354,6 +420,7 @@
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
return '<span class="badge bg-warning text-dark">pending</span>';
}
@ -366,6 +433,32 @@
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
if (hasReceipt) {
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
const period = orderData.coverage_start && orderData.coverage_end
? `${orderData.coverage_start} ${orderData.coverage_end}`
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
document.getElementById('receiptPeriod').textContent = period;
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
const link = document.getElementById('economicOrderLink');
link.classList.toggle('d-none', !orderData.economic_order_url);
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
}
}
function renderLines() {
@ -378,9 +471,11 @@
tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported';
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
const lockLine = isExportedLine && !mayEditExportedDraft;
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
@ -406,17 +501,17 @@
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
${isExportedLine ? 'disabled' : ''}
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
${isExportedLine ? 'disabled' : ''}
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
${isExportedLine ? 'disabled' : ''}
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
@ -502,7 +597,7 @@
const product = data.product;
line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || '';
if (!Number(line.unit_price || 0) && product.sales_price != null) {
if (line.unit_price == null && product.sales_price != null) {
line.unit_price = Number(product.sales_price || 0);
}
if (product.ean) {
@ -542,7 +637,8 @@
discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk',
product_id: line.product.productNumber || null,
product_id: null,
economic_product_number: line.product.productNumber || null,
selected: line.selected !== false,
export_status: line.export_status || null,
};
@ -569,13 +665,25 @@
document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || '';
document.getElementById('customerIdentity').textContent = orderData.customer_name
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
: '-';
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = orderData.last_exported_at ? true : false;
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
const errorBanner = document.getElementById('exportErrorBanner');
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
const openEconomicDraft = orderData.sync_status === 'exported' && orderData.economic_order_number && !orderData.economic_invoice_number;
document.getElementById('economicUpdateBtn').classList.toggle('d-none', !openEconomicDraft);
document.getElementById('economicStatusBtn').classList.toggle('d-none', !orderData.economic_order_number);
document.getElementById('economicPaymentStatusView').textContent = orderData.economic_payment_status || (orderData.economic_document_state === 'draft' ? 'Ikke faktureret' : '-');
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
@ -666,9 +774,18 @@
}
body.innerHTML = items.map((event) => {
const payload = typeof event.event_payload === 'object'
? JSON.stringify(event.event_payload, null, 2)
: String(event.event_payload || '');
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
let payload = String(event.event_payload || '');
if (event.event_type === 'export_success') {
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
} else if (event.event_type === 'export_attempt') {
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
} else if (event.event_type === 'sync_status_manual_update') {
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
} else if (typeof event.event_payload === 'object') {
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
}
return `
<tr>
@ -676,7 +793,7 @@
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_status || '-')}</td>
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
</tr>
`;
}).join('');
@ -761,7 +878,99 @@
}
}
async function exportOrder() {
async function previewEconomicUpdate() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-preview`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({lines: orderLines, notes: document.getElementById('orderNotes').value || null}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Forhåndsvisning fejlede');
pendingEconomicUpdateId = data.request_id;
const before = data.before || {}, after = data.after || {};
const rows = (after.lines || []).map((line, i) => {
const old = (before.lines || [])[i] || {};
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td style="white-space:pre-line">${escapeHtml(old.description)} → ${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)} → ${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)} → ${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)} → ${escapeHtml(line.discount_percentage)}</td></tr>`;
}).join('');
document.getElementById('economicUpdateComparison').innerHTML = `<div class="mb-3"><strong>Noter:</strong> ${escapeHtml(before.notes || '-')} → ${escapeHtml(after.notes || '-')}</div><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Vare</th><th>Beskrivelse</th><th>Antal</th><th>Pris</th><th>Rabat %</th></tr></thead><tbody>${rows}</tbody></table></div>`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicUpdatePreviewModal')).show();
} catch (err) { showToast(err.message, 'danger'); }
}
async function confirmEconomicUpdate() {
if (!pendingEconomicUpdateId) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-confirm`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:pendingEconomicUpdateId})});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opdateringen fejlede');
bootstrap.Modal.getInstance(document.getElementById('economicUpdatePreviewModal'))?.hide();
pendingEconomicUpdateId = null;
showToast('e-conomic-ordrekladden er opdateret', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function refreshEconomicStatus() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-status-refresh`, {method:'POST'});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Statuskontrol fejlede');
showToast(data.invoice_number ? `Faktura ${data.invoice_number}: ${data.payment_status || data.status}` : 'Ordren er stadig en åben kladde', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function previewPrices() {
try {
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
const preview = await res.json();
if (!res.ok) throw Error(preview.detail || 'Prisberegning fejlede');
const summary = preview.changes.map(x => `${x.description}: ${x.before} → ${x.after}`).join('\n');
if (!preview.changes.length) { showToast('Ingen prisændringer foreslået. Abonnementspriser bevares.', 'info'); return; }
if (!confirm('Genberegn den GEMTE kladde? Ikke-gemte ændringer indgår ikke.\nAbonnementspriser bevares.\n\n'+summary)) return;
const saved = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:preview.token})});
const data = await saved.json();
if (!saved.ok) throw Error(data.detail || 'Kunne ikke gemme priser');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
function showEconomicProductPreview(detail) {
pendingEconomicProductPreview = detail;
const groups = detail.product_groups || [];
document.getElementById('economicProductPreviewRows').innerHTML = (detail.products || []).map((product, index) => {
const options = groups.map(group => {
const selected = Number(product.suggested_group_number) === Number(group.number) ? ' selected' : '';
return `<option value="${Number(group.number)}"${selected}>${escapeHtml(group.number)} · ${escapeHtml(group.name)}</option>`;
}).join('');
return `<div class="card mb-3" data-economic-product="${escapeHtml(product.product_number)}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Opret produkt ${escapeHtml(product.product_number)}</strong>
<span class="badge bg-success">${formatCurrency(product.sales_price)}</span>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-4"><div class="small text-muted">Navn</div><div>${escapeHtml(product.name || '-')}</div></div>
<div class="col-md-4"><div class="small text-muted">EAN / stregkode</div><div>${escapeHtml(product.ean || '-')}</div></div>
<div class="col-md-4"><label class="small text-muted" for="economicGroup${index}">Varegruppe</label><select id="economicGroup${index}" class="form-select form-select-sm" data-product-group>${options}</select></div>
<div class="col-12"><div class="small text-muted">Beskrivelse</div><div>${escapeHtml(product.description || '-')}</div></div>
</div></div>
</div>`;
}).join('');
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicProductPreviewModal')).show();
}
async function confirmEconomicProductCreation() {
if (!pendingEconomicProductPreview) return;
const approvals = {};
document.querySelectorAll('[data-economic-product]').forEach(card => {
approvals[card.dataset.economicProduct] = Number(card.querySelector('[data-product-group]').value);
});
bootstrap.Modal.getInstance(document.getElementById('economicProductPreviewModal'))?.hide();
await exportOrder(approvals);
}
async function exportOrder(createMissingProducts = {}) {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning');
@ -782,15 +991,18 @@
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
currency: line.currency || 'DKK',
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
ean: line.ean || null,
selected: true,
})),
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked,
create_missing_products: createMissingProducts,
};
try {
@ -801,6 +1013,10 @@
});
const data = await res.json();
if (!res.ok) {
if (data.detail && data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
throw new Error(data.detail || 'Eksport fejlede');
}

View File

@ -843,11 +843,31 @@ async def sag_detaljer(request: Request, sag_id: int):
c.phone,
c.mobile,
c.title,
company.customer_name
company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
WHERE cc_match.contact_id = c.id
AND cc_match.customer_id IN (
SELECT s_match.customer_id
FROM sag_sager s_match
WHERE s_match.id = sk.sag_id AND s_match.customer_id IS NOT NULL
UNION
SELECT sk_customer.customer_id
FROM sag_kunder sk_customer
WHERE sk_customer.sag_id = sk.sag_id AND sk_customer.deleted_at IS NULL
)
) AS is_external
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
SELECT cu.id AS customer_id, cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
@ -1204,11 +1224,25 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
c.phone,
c.mobile,
c.title,
company.customer_name
company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
JOIN sag_sager s_match ON s_match.id = sk.sag_id
WHERE cc_match.contact_id = c.id
AND s_match.customer_id IS NOT NULL
AND cc_match.customer_id = s_match.customer_id
) AS is_external
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.name AS customer_name
SELECT cu.id AS customer_id, cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id

View File

@ -2097,6 +2097,36 @@
border-bottom: none;
}
.contact-row.contact-external {
background: #fce7f3;
border: 2px solid #be185d;
border-left: 8px solid #be185d;
border-radius: 0.45rem;
margin: 0.3rem 0;
padding-left: 0.5rem;
}
.external-contact-badge {
color: #fff;
background: #be185d;
border: 1px solid #831843;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.contact-search-external {
background: #fce7f3;
border: 2px solid #be185d !important;
border-left: 8px solid #be185d !important;
margin-bottom: 0.35rem;
}
[data-bs-theme="dark"] .contact-row.contact-external,
[data-bs-theme="dark"] .contact-search-external {
background: rgba(190, 24, 93, 0.3);
}
.contact-row .contact-name {
font-weight: 600;
}
@ -8034,8 +8064,9 @@
<span class="text-end">Handlinger</span>
</div>
{% for contact in contacts %}
{% set external_contact = contact.is_external or (contact.customer_name and customer and contact.customer_name|lower != customer.name|lower) or (contact.customer_name and not customer) %}
<div
class="contact-row"
class="contact-row{% if external_contact %} contact-external{% endif %}"
role="button"
tabindex="0"
onclick="showContactInfoModal(this)"
@ -8049,9 +8080,17 @@
data-role="{{ contact.role|default('Kontakt')|replace('"', '&quot;') }}"
data-is-primary="{{ 'true' if contact.is_primary else 'false' }}"
>
<div class="contact-name">{{ contact.contact_name }}</div>
<div class="contact-name">
{{ contact.contact_name }}
{% if external_contact %}
<span class="badge external-contact-badge ms-1">Ekstern kontakt</span>
{% endif %}
</div>
<small>{{ contact.title or '-' }}</small>
<small>{{ contact.customer_name or '-' }}</small>
<small>
{{ contact.customer_name or 'Firma ikke registreret' }}
{% if external_contact %}<br><strong style="color:#9d174d;">Andet firma end sagens kunde</strong>{% endif %}
</small>
<div class="contact-actions">
{% if contact.mobile %}
<button
@ -15166,6 +15205,31 @@
<div class="input-group mb-3">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="entitySearchInput" placeholder="Søg (min. 2 tegn)..." autocomplete="off">
<button type="button" class="btn btn-primary d-none" id="quickContactCreateBtn" onclick="toggleQuickContactCreate(true)">
<i class="bi bi-person-plus me-1"></i>Opret kontakt
</button>
</div>
<div id="quickContactCreate" class="d-none border rounded p-3 mb-3 bg-light">
<div class="d-flex justify-content-between align-items-center mb-3">
<strong><i class="bi bi-person-plus me-1"></i>Ny kontakt</strong>
<button type="button" class="btn-close" onclick="toggleQuickContactCreate(false)"></button>
</div>
<div class="row g-2">
<div class="col-sm-6"><input id="quickContactFirstName" class="form-control" placeholder="Fornavn *"></div>
<div class="col-sm-6"><input id="quickContactLastName" class="form-control" placeholder="Efternavn"></div>
<div class="col-sm-6"><input id="quickContactEmail" type="email" class="form-control" placeholder="E-mail"></div>
<div class="col-sm-6"><input id="quickContactMobile" class="form-control" placeholder="Mobil/telefon"></div>
<div class="col-12 position-relative">
<input id="quickContactCompanySearch" class="form-control" placeholder="Søg firma (valgfrit)" autocomplete="off">
<input id="quickContactCompanyId" type="hidden">
<div id="quickContactCompanyResults" class="list-group position-absolute w-100 shadow d-none" style="z-index:1080;max-height:180px;overflow:auto;"></div>
</div>
<div class="col-12"><div id="quickContactStatus" class="small"></div></div>
<div class="col-12 d-flex gap-2 justify-content-end">
<button type="button" class="btn btn-outline-secondary" onclick="toggleQuickContactCreate(false)">Annuller</button>
<button type="button" class="btn btn-success" id="quickContactSaveBtn" onclick="createAndAttachQuickContact()">Opret og tilknyt</button>
</div>
</div>
</div>
<div class="text-center d-none" id="entitySearchSpinner">
<div class="spinner-border text-primary" role="status"></div>
@ -15777,6 +15841,8 @@
document.getElementById('entitySearchTitle').textContent = titles[type] || 'Søg';
document.getElementById('entitySearchInput').value = '';
document.getElementById('entitySearchResults').innerHTML = '';
document.getElementById('quickContactCreateBtn').classList.toggle('d-none', type !== 'contact');
toggleQuickContactCreate(false);
const modal = new bootstrap.Modal(document.getElementById('entitySearchModal'));
modal.show();
@ -15784,6 +15850,94 @@
setTimeout(() => document.getElementById('entitySearchInput').focus(), 500);
}
function toggleQuickContactCreate(show) {
const panel = document.getElementById('quickContactCreate');
if (!panel) return;
panel.classList.toggle('d-none', !show);
if (show) {
document.getElementById('quickContactCompanyId').value = {{ case.customer_id or "''" }};
document.getElementById('quickContactCompanySearch').value = {{ (customer.name if customer else '')|tojson }};
setTimeout(() => document.getElementById('quickContactFirstName').focus(), 50);
}
}
function quickCreateFromSearch(encodedQuery) {
const query = decodeURIComponent(encodedQuery || '').trim();
toggleQuickContactCreate(true);
document.getElementById('quickContactFirstName').value = '';
document.getElementById('quickContactLastName').value = '';
document.getElementById('quickContactEmail').value = '';
document.getElementById('quickContactMobile').value = '';
if (query.includes('@')) {
document.getElementById('quickContactEmail').value = query;
const localName = query.split('@')[0].replace(/[._-]+/g, ' ').trim();
document.getElementById('quickContactFirstName').value = localName;
} else {
const nameParts = query.split(/\s+/).filter(Boolean);
document.getElementById('quickContactFirstName').value = nameParts.shift() || '';
document.getElementById('quickContactLastName').value = nameParts.join(' ');
}
document.getElementById('quickContactStatus').textContent = '';
document.getElementById('quickContactFirstName').focus();
}
let quickCompanyTimer;
document.getElementById('quickContactCompanySearch').addEventListener('input', function(event) {
document.getElementById('quickContactCompanyId').value = '';
clearTimeout(quickCompanyTimer);
const query = event.target.value.trim();
const results = document.getElementById('quickContactCompanyResults');
if (query.length < 2) { results.classList.add('d-none'); return; }
quickCompanyTimer = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
const companies = response.ok ? await response.json() : [];
results.innerHTML = companies.map(company => `
<button type="button" class="list-group-item list-group-item-action" onclick="selectQuickContactCompany(${company.id}, decodeURIComponent('${encodeURIComponent(company.name)}'))">
<strong>${escapeHtml(company.name)}</strong>
</button>`).join('') || '<div class="list-group-item text-muted">Ingen firmaer fundet</div>';
results.classList.remove('d-none');
} catch (_) { results.classList.add('d-none'); }
}, 250);
});
function selectQuickContactCompany(id, name) {
document.getElementById('quickContactCompanyId').value = id;
document.getElementById('quickContactCompanySearch').value = name;
document.getElementById('quickContactCompanyResults').classList.add('d-none');
}
async function createAndAttachQuickContact() {
const firstName = document.getElementById('quickContactFirstName').value.trim();
const status = document.getElementById('quickContactStatus');
const button = document.getElementById('quickContactSaveBtn');
if (!firstName) { status.className = 'small text-danger'; status.textContent = 'Fornavn er påkrævet.'; return; }
const companyId = Number(document.getElementById('quickContactCompanyId').value || 0);
const payload = {
first_name: firstName,
last_name: document.getElementById('quickContactLastName').value.trim(),
email: document.getElementById('quickContactEmail').value.trim() || null,
mobile: document.getElementById('quickContactMobile').value.trim() || null,
company_id: companyId || null,
is_primary: Boolean(companyId)
};
button.disabled = true;
status.className = 'small text-muted'; status.textContent = 'Opretter kontakt…';
try {
const createResponse = await fetch('/api/v1/contacts', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)});
const contact = await createResponse.json().catch(() => ({}));
if (!createResponse.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes');
const attachResponse = await fetch(`/api/v1/sag/${caseIds}/contacts`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({contact_id:contact.id, role:'Kontakt'})});
const attachResult = await attachResponse.json().catch(() => ({}));
if (!attachResponse.ok) throw new Error(attachResult.detail || 'Kontakten blev oprettet, men kunne ikke tilknyttes');
bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide();
reloadCasePreservingContext();
} catch (error) {
status.className = 'small text-danger'; status.textContent = error.message;
button.disabled = false;
}
}
document.getElementById('entitySearchInput').addEventListener('input', function(e) {
clearTimeout(searchDebounceIds);
const query = e.target.value.trim();
@ -15809,7 +15963,7 @@
const res = await fetch(url);
if (!res.ok) throw new Error('Search failed');
const results = await res.json();
renderResults(results);
renderResults(results, query);
} catch (e) {
console.error(e);
document.getElementById('entitySearchResults').innerHTML = '<div class="text-danger text-center p-3">Fejl ved søgning</div>';
@ -15819,15 +15973,34 @@
}
}
function renderResults(results) {
function renderResults(results, query = '') {
const container = document.getElementById('entitySearchResults');
if (results.length === 0) {
if (currentSearchType === 'contact') {
const safeQuery = escapeHtml(query);
const encodedQuery = encodeURIComponent(query).replace(/'/g, '%27');
container.innerHTML = `
<div class="text-center border rounded p-4 bg-light">
<i class="bi bi-person-x fs-3 text-muted d-block mb-2"></i>
<div class="text-muted mb-3">Ingen kontakt fundet for <strong>${safeQuery}</strong></div>
<button type="button" class="btn btn-success" onclick="quickCreateFromSearch('${encodedQuery}')">
<i class="bi bi-person-plus me-1"></i>Opret ${safeQuery} som ny kontakt
</button>
</div>`;
} else {
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
}
return;
}
const caseCustomerIds = [
{% if case.customer_id %}{{ case.customer_id }},{% endif %}
{% for linked_customer in customers %}{{ linked_customer.customer_id }},{% endfor %}
].map(Number);
container.innerHTML = results.map(item => {
let title = '', subtitle = '', icon = '', id = item.id;
let extraClass = '';
let relationBadge = '';
if (currentSearchType === 'hardware') {
title = `${item.brand} ${item.model}`;
@ -15839,8 +16012,14 @@
icon = 'bi-geo-alt';
} else if (currentSearchType === 'contact') {
title = `${item.first_name} ${item.last_name}`;
subtitle = item.email;
const companyIds = Array.isArray(item.customer_ids) ? item.customer_ids.map(Number) : [];
const isExternal = !companyIds.some((id) => caseCustomerIds.includes(id));
subtitle = [item.email, item.user_company].filter(Boolean).join(' • ');
icon = 'bi-person';
if (isExternal) {
extraClass = 'contact-search-external';
relationBadge = '<span class="badge external-contact-badge ms-2">Ekstern kontakt</span>';
}
} else if (currentSearchType === 'customer') {
title = item.name;
subtitle = `CVR: ${item.cvr_nummer || 'N/A'}`;
@ -15848,10 +16027,10 @@
}
return `
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center" onclick="addEntity(${id})">
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center ${extraClass}" onclick="addEntity(${id})">
<div class="me-3 fs-4 text-muted"><i class="bi ${icon}"></i></div>
<div>
<div class="fw-bold">${title}</div>
<div class="fw-bold">${title}${relationBadge}</div>
<small class="text-muted">${subtitle}</small>
</div>
</button>

View File

@ -7,10 +7,10 @@ router = APIRouter()
async def search_customers(q: str = Query(..., min_length=2)):
"""
Autocomplete search for customers.
Returns list of {id, name, cvr_nummer, email}
Returns customer identity and registered address for address-aware workflows.
"""
sql = """
SELECT id, name, cvr_number as cvr_nummer, email
SELECT id, name, cvr_number as cvr_nummer, email, address, city, postal_code
FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
AND deleted_at IS NULL
@ -45,6 +45,19 @@ async def search_contacts(q: str = Query(..., min_length=2)):
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company,
(
SELECT cu.id
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company_id,
ARRAY(
SELECT cc.customer_id
FROM contact_companies cc
WHERE cc.contact_id = c.id
) AS customer_ids,
(
SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
FROM contact_companies cc
@ -61,11 +74,17 @@ async def search_contacts(q: str = Query(..., min_length=2)):
OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc_search
JOIN customers cu_search ON cu_search.id = cc_search.customer_id
WHERE cc_search.contact_id = c.id AND cu_search.name ILIKE %s
)
ORDER BY c.first_name ASC, c.last_name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term, term, term, term))
results = execute_query(sql, (term, term, term, term, term, term, term))
return results
@router.get("/search/hardware")

View File

@ -0,0 +1,58 @@
"""Server-side product cost filtering, including nested snapshots and audit records."""
import json
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
COST_KEYS = {'cost_price', 'supplier_price', 'purchase_price', 'costPrice', 'unitCostPrice',
'marginInBaseCurrency', 'marginPercentage', 'economic_snapshot', 'cost_currency',
'purchasePrice', 'buy_price', 'cost', 'supplier_cost'}
def without_cost(value):
if isinstance(value, list):
return [without_cost(v) for v in value if not (isinstance(v, dict) and
(v.get('price_type') in COST_KEYS or any(word in str(v.get('event_type', '')) for word in ('supplier', 'cost'))))]
if isinstance(value, dict):
return {key: without_cost(val) for key, val in value.items() if key not in COST_KEYS}
return value
class ProductAccessRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def secured(request):
actor = await get_current_user(request, await security(request))
def permitted(code):
return bool(actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], code))
can_cost = permitted('economic.cost.view')
# Supplier offers and gateway payloads may contain provider-specific price aliases.
if not can_cost and any(segment in request.url.path for segment in ('supplier', 'apigateway', 'gateway')):
raise HTTPException(403, 'Du mangler rettighed til leverandørpriser')
if request.method not in ('GET', 'HEAD') and not permitted('products.update'):
raise HTTPException(403, 'Du mangler rettighed til produktredigering')
if request.url.path.endswith('/apigateway-sync') and request.query_params.get('auto_create', 'true').lower() not in ('false','0') and not permitted('products.update'):
raise HTTPException(403, 'Automatisk vareoprettelse kræver produktredigering')
if request.method == 'POST' and request.url.path.endswith('/price') and not permitted('economic.pricing.manage'):
raise HTTPException(403, 'Du mangler rettighed til prisændringer')
if request.method == 'PATCH' and request.path_params.get('product_id') and 'name' in await request.json():
from app.core.database import execute_query_single
linked = execute_query_single('SELECT economic_product_number FROM products WHERE id=%s', (request.path_params['product_id'],))
if linked and linked.get('economic_product_number') and not permitted('economic.catalog.manage'):
raise HTTPException(403, 'Du mangler rettighed til navneændring af en e-conomic-vare')
response = await handler(request)
if 'application/json' in response.headers.get('content-type', ''):
body = json.loads(response.body)
if not can_cost:
body = without_cost(body)
if request.method == 'GET' and isinstance(body, dict) and 'id' in body and 'name' in body:
body['_permissions'] = {'cost_view':can_cost,'update':permitted('products.update'),
'pricing':permitted('economic.pricing.manage')}
headers = {k:v for k,v in response.headers.items() if k not in ('content-length', 'content-type')}
return JSONResponse(body, status_code=response.status_code, headers=headers, background=response.background)
return response
return secured

View File

@ -0,0 +1,397 @@
"""Verified e-conomic identities, durable catalog work and deterministic prices."""
import asyncio
import json
import logging
import random
from contextlib import contextmanager
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import quote, urljoin, urlparse
import aiohttp
from fastapi import HTTPException
from psycopg2.extras import Json, RealDictCursor
from app.core.config import settings
from app.core.database import get_db_connection, release_db_connection
from app.core.economic_write_policy import assert_economic_write_allowed
logger = logging.getLogger(__name__)
@contextmanager
def transaction():
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
yield cur
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
def query(sql, params=(), one=False):
with transaction() as cur:
cur.execute(sql, params)
if cur.description:
return cur.fetchone() if one else cur.fetchall()
class RemoteError(Exception):
def __init__(self, status, message, retry_after=0):
super().__init__(message)
self.status = status
self.retry_after = retry_after
class EconomicClient:
def __init__(self):
self.base = settings.ECONOMIC_API_URL.rstrip('/') + '/'
async def request(self, method, path, payload=None):
url = urljoin(self.base, path)
if urlparse(url).netloc != urlparse(self.base).netloc or urlparse(url).scheme != 'https':
raise RemoteError(400, 'Ugyldig e-conomic-adresse')
if method != 'GET' and (settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN):
raise RemoteError(423, 'Safety mode: ekstern skrivning er slået fra')
if method != 'GET':
try:
assert_economic_write_allowed(method, path)
except HTTPException as exc:
raise RemoteError(exc.status_code, exc.detail) from exc
headers = {'X-AppSecretToken': settings.ECONOMIC_APP_SECRET_TOKEN,
'X-AgreementGrantToken': settings.ECONOMIC_AGREEMENT_GRANT_TOKEN}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.request(method, url, headers=headers, json=payload, allow_redirects=False) as response:
if 300 <= response.status < 400:
raise RemoteError(502, 'e-conomic returnerede en uventet omdirigering')
try:
body = await response.json(content_type=None)
except (ValueError, UnicodeDecodeError):
body = {}
if response.status >= 400:
# Never retain full payloads, headers or provider HTML in logs.
message = body.get('message', 'e-conomic afviste kaldet') if isinstance(body, dict) else 'e-conomic afviste kaldet'
for secret in headers.values():
if secret:
message = str(message).replace(secret, '[redacted]')
retry = response.headers.get('Retry-After', '0')
raise RemoteError(response.status, f'{response.status}: {str(message)[:400]}', int(retry) if retry.isdigit() else 0)
if not isinstance(body, dict):
raise RemoteError(502, 'Uventet svar fra e-conomic')
return body
async def collection(self, path, checkpoint_job=None):
result, seen = [], set()
next_page = path + ('&' if '?' in path else '?') + 'pagesize=1000'
while next_page:
if next_page in seen:
raise RemoteError(502, 'Gentaget side i e-conomic-import')
seen.add(next_page)
cached = query('SELECT payload FROM economic_catalog_import_pages WHERE job_id=%s AND page_key=%s', (checkpoint_job, next_page), one=True) if checkpoint_job else None
page = cached['payload'] if cached else await self.request('GET', next_page)
if not isinstance(page.get('collection'), list):
raise RemoteError(502, 'Importsvaret mangler en vareliste')
if checkpoint_job and not cached:
query('INSERT INTO economic_catalog_import_pages(job_id,page_key,ordinal,payload,next_page) VALUES(%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING', (checkpoint_job, next_page, len(seen), Json(page), page.get('pagination', {}).get('nextPage')))
result.extend(page['collection'])
next_page = page.get('pagination', {}).get('nextPage')
return result
async def verify(self, connection_id):
connection = query('SELECT * FROM economic_catalog_connections WHERE id=%s', (connection_id,), one=True)
if not connection:
raise RemoteError(409, 'Vælg e-conomic-forbindelse')
agreement = await self.request('GET', 'self')
number = str(agreement.get('agreementNumber') or '')
if not number or number != connection['agreement_number']:
raise RemoteError(409, 'e-conomic-aftalen afviger fra den registrerede forbindelse')
if (agreement.get('settings') or {}).get('baseCurrency') != connection['currency']:
raise RemoteError(409, 'Aftalens basisvaluta afviger; import og eksport er stoppet')
return connection
def product_number(value):
if not isinstance(value, str) or not value.strip() or len(value) > 25:
raise HTTPException(422, 'Varenummer skal være tekst med 125 tegn')
return value
def audit(cur, product_id, action, changes, user_id=None):
cur.execute('INSERT INTO product_audit_log(product_id,event_type,user_id,changes) VALUES(%s,%s,%s,%s)',
(product_id, action, user_id, Json(changes)))
def import_product(connection, remote, user_id=None):
number = product_number(remote.get('productNumber'))
name = str(remote.get('name') or '').strip()
if not name or len(name) > 255:
raise RemoteError(422, f'Vare {number}: navnet mangler eller overstiger Hub-grænsen på 255 tegn')
with transaction() as cur:
cur.execute('SELECT * FROM products WHERE economic_connection_id=%s AND economic_product_number=%s FOR UPDATE', (connection['id'], number))
existing = cur.fetchone()
group = (remote.get('productGroup') or {}).get('productGroupNumber')
unit = (remote.get('unit') or {}).get('unitNumber')
if existing:
if existing['economic_snapshot'] == remote and existing['economic_sync_status'] == 'synced' and existing['name'] == name:
return 'unchanged'
conflict = existing['name'] != name
pending = existing['economic_sync_status'] == 'pending'
baseline = (existing['economic_snapshot'] or {}).get('name')
external_change = name not in (baseline, existing['name'])
state = ('conflict' if external_change else
('local_only' if existing['economic_sync_status'] == 'local_only' and conflict else
('pending' if pending and conflict else ('conflict' if conflict else 'synced'))))
snapshot = dict(remote)
if pending and conflict and not external_change:
snapshot['name'] = baseline
cur.execute('''UPDATE products SET economic_product_group_number=%s,
economic_sales_price_reference=%s,economic_currency=%s,economic_unit_number=%s,
is_active_in_economic=%s,economic_snapshot=%s,economic_sync_status=%s,
economic_last_synced_at=now() WHERE id=%s''',
(group, remote.get('salesPrice'), connection['currency'], unit, not remote.get('barred', False), Json(snapshot), state, existing['id']))
return 'conflict' if state == 'conflict' else 'updated'
cur.execute('''INSERT INTO products(name,status,economic_connection_id,economic_product_number,
economic_product_group_number,economic_sales_price_reference,economic_currency,
economic_unit_number,is_active_in_economic,economic_snapshot,economic_sync_status,economic_last_synced_at,category_id)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'synced',now(),
(SELECT category_id FROM economic_catalog_references WHERE connection_id=%s AND kind='product-groups' AND number=%s)) RETURNING id''',
(name, 'inactive' if remote.get('barred') else 'active', connection['id'], number,
group, remote.get('salesPrice'), connection['currency'], unit, not remote.get('barred', False), Json(remote), connection['id'], group))
audit(cur, cur.fetchone()['id'], 'economic_import', {'number': number}, user_id)
return 'created'
async def link_product(product_id, connection_id, number, user_id):
number = product_number(number)
client = EconomicClient()
connection = await client.verify(connection_id)
remote = await client.request('GET', 'products/' + quote(number, safe=''))
if remote.get('productNumber') != number:
raise HTTPException(409, 'Det returnerede varenummer afviger')
with transaction() as cur:
cur.execute('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (product_id,))
product = cur.fetchone()
if not product:
raise HTTPException(404, 'Vare ikke fundet')
if product['economic_product_number']:
raise HTTPException(409, 'Varen er allerede koblet; koblingen er låst')
cur.execute('SELECT id FROM products WHERE economic_connection_id=%s AND economic_product_number=%s', (connection_id, number))
if cur.fetchone():
raise HTTPException(409, 'Varenummeret er allerede koblet til et andet Hub-produkt')
cur.execute('''UPDATE products SET economic_connection_id=%s,economic_product_number=%s,
economic_snapshot=%s,economic_sync_status='pending',updated_by=%s WHERE id=%s''',
(connection_id, number, Json(remote), user_id, product_id))
audit(cur, product_id, 'economic_link', {'number': number}, user_id)
import_product(connection, remote, user_id)
# Initial name ownership is explicit; mismatch is a visible conflict.
query("UPDATE products SET economic_sync_status=CASE WHEN name=%s THEN 'synced' ELSE 'conflict' END WHERE id=%s", (remote['name'], product_id))
return {'linked': True, 'number': number}
def enqueue(connection_id, kind, user_id, product_id=None, payload=None):
with transaction() as cur:
# Serialize reservation/import enqueue for this agreement.
cur.execute('SELECT * FROM economic_catalog_connections WHERE id=%s FOR UPDATE', (connection_id,))
connection = cur.fetchone()
if not connection:
raise HTTPException(404, 'Forbindelsen findes ikke')
if kind in ('import', 'preview'):
cur.execute("SELECT id FROM economic_catalog_jobs WHERE connection_id=%s AND kind=%s AND status IN ('pending','running','retry')", (connection_id, kind))
else:
cur.execute("SELECT id FROM economic_catalog_jobs WHERE product_id=%s AND kind='create' AND status NOT IN ('failed','cancelled')", (product_id,))
previous = cur.fetchone()
if previous:
return {'id': previous['id']}
data = dict(payload or {})
if kind == 'create':
cur.execute('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (product_id,))
product = cur.fetchone()
if not product or product['economic_product_number']:
raise HTTPException(409, 'Varen mangler eller er allerede koblet')
data['productNumber'] = product_number(connection['number_prefix'] + str(connection['next_number']))
data['name'] = product['name']
cur.execute('UPDATE economic_catalog_connections SET next_number=next_number+1 WHERE id=%s', (connection_id,))
cur.execute('''INSERT INTO economic_catalog_jobs(connection_id,product_id,kind,payload,created_by)
VALUES(%s,%s,%s,%s,%s) RETURNING id''', (connection_id, product_id, kind, Json(data), user_id))
return dict(cur.fetchone())
async def process_job(job):
client = EconomicClient()
connection = await client.verify(job['connection_id'])
if job['kind'] == 'name':
# Product names stay local. This is intentionally not an e-conomic edit.
return {'local_only': True}
if job['kind'] == 'create' and not connection['enabled']:
raise RemoteError(423, 'Aktivér forbindelsen før ekstern skrivning')
if job['kind'] in ('import', 'preview'):
for kind, number_key in [('product-groups', 'productGroupNumber'), ('units', 'unitNumber')]:
for row in await client.collection(kind):
query('''INSERT INTO economic_catalog_references(connection_id,kind,number,name,payload)
VALUES(%s,%s,%s,%s,%s) ON CONFLICT(connection_id,kind,number)
DO UPDATE SET name=EXCLUDED.name,payload=EXCLUDED.payload''',
(connection['id'], kind, row[number_key], row.get('name', ''), Json(row)))
# Fetch completes before applying products; partial retrieval never masquerades as complete.
products = await client.collection('products', checkpoint_job=job['id'])
result = {'created': 0, 'updated': 0, 'unchanged': 0, 'conflict': 0, 'unlinked': 0, 'total': len(products)}
for remote in products:
if job['kind'] == 'preview':
found = query('SELECT id,name,economic_snapshot FROM products WHERE economic_connection_id=%s AND economic_product_number=%s', (connection['id'], remote['productNumber']), one=True)
state = 'unlinked' if not found else ('unchanged' if found['economic_snapshot'] == remote and found['name'] == remote['name'] else 'updated' if found['name'] == remote['name'] else 'conflict')
else:
state = import_product(connection, remote, job['created_by'])
result[state] += 1
if job['kind'] == 'import':
query('UPDATE economic_catalog_connections SET last_synced_at=now() WHERE id=%s', (connection['id'],))
return result
product = query('SELECT * FROM products WHERE id=%s', (job['product_id'],), one=True)
if not product or product['deleted_at']:
raise RemoteError(409, 'Varen er deaktiveret eller slettet')
if job['kind'] == 'name':
if product['economic_sync_status'] == 'conflict':
raise RemoteError(409, 'Navnekonflikten skal løses før synkronisering')
if job['version'] != product['economic_name_version']:
return {'superseded': True}
number = product['economic_product_number']
remote = await client.request('GET', 'products/' + quote(number, safe=''))
baseline = (product['economic_snapshot'] or {}).get('name')
if remote['name'] not in (baseline, product['name']) and not job['payload'].get('force'):
query("UPDATE products SET economic_sync_status='conflict',economic_snapshot=%s WHERE id=%s", (Json(remote), product['id']))
raise RemoteError(409, 'Navnet er også ændret i e-conomic; vælg hvilket navn der skal bruges')
allowed = ('productNumber','name','description','costPrice','recommendedPrice','salesPrice','barCode','barred','unit','productGroup','departmentalDistribution')
payload = {key: remote[key] for key in allowed if key in remote}
payload['name'] = product['name']
await client.request('PUT', 'products/' + quote(number, safe=''), payload)
query('''UPDATE products SET economic_snapshot=%s,economic_sync_status='synced',economic_last_synced_at=now()
WHERE id=%s AND economic_name_version=%s''', (Json(payload), product['id'], job['version']))
return {'number': number}
if job['kind'] == 'create':
payload = dict(job['payload'])
number = payload['productNumber']
# A known collision is not our product. Ambiguous prior POST is never retried automatically.
try:
await client.request('GET', 'products/' + quote(number, safe=''))
except RemoteError as exc:
if exc.status != 404:
raise
else:
raise RemoteError(409, 'Det reserverede nummer findes allerede; vælg et nyt oprettelsesforsøg')
group = payload.get('productGroup', {}).get('productGroupNumber')
reference = query("SELECT * FROM economic_catalog_references WHERE connection_id=%s AND kind='product-groups' AND number=%s", (connection['id'], group), one=True)
if not reference or not reference['allow_new'] or reference['phasing_out']:
raise RemoteError(422, 'Vælg en tilladt varegruppe')
await client.request('GET', f'product-groups/{int(group)}')
if payload.get('unit'):
await client.request('GET', f"units/{int(payload['unit']['unitNumber'])}")
# Persist that a POST can have happened before making it.
query("UPDATE economic_catalog_jobs SET status='uncertain' WHERE id=%s", (job['id'],))
remote = await client.request('POST', 'products', payload)
if remote.get('productNumber') != number:
raise RemoteError(502, 'Oprettelsessvaret skal afklares manuelt')
with transaction() as cur:
cur.execute('UPDATE products SET economic_connection_id=%s,economic_product_number=%s WHERE id=%s', (connection['id'], number, product['id']))
audit(cur, product['id'], 'economic_created', {'number': number}, job['created_by'])
import_product(connection, remote, job['created_by'])
return {'number': number}
raise RemoteError(422, 'Ukendt jobtype')
async def run_catalog_jobs():
# Single DB advisory lock works across web workers. Keep this connection for the lease.
with transaction() as lock:
lock.execute("SELECT to_regclass('economic_catalog_jobs') AS present")
if not lock.fetchone()['present']:
return
lock.execute('SELECT pg_try_advisory_xact_lock(24201) AS acquired')
if not lock.fetchone()['acquired']:
return
query("UPDATE economic_catalog_jobs SET status=CASE WHEN kind='create' THEN 'uncertain' ELSE 'retry' END WHERE status='running' AND lease_until < now()")
for connection in query('SELECT * FROM economic_catalog_connections WHERE enabled AND auto_sync AND (next_sync_at IS NULL OR next_sync_at<=now())'):
enqueue(connection['id'], 'import', None)
query("UPDATE economic_catalog_connections SET next_sync_at=now()+sync_interval_minutes*interval '1 minute' WHERE id=%s", (connection['id'],))
jobs = query("SELECT * FROM economic_catalog_jobs WHERE status IN ('pending','retry') AND next_attempt_at<=now() ORDER BY created_at LIMIT 5")
for job in jobs:
query("UPDATE economic_catalog_jobs SET status='running',attempts=attempts+1,lease_until=now()+interval '10 minutes',updated_at=now() WHERE id=%s", (job['id'],))
try:
result = await process_job(job)
query("UPDATE economic_catalog_jobs SET status='done',result=%s,error=NULL,updated_at=now() WHERE id=%s", (Json(result), job['id']))
except (RemoteError, aiohttp.ClientError, asyncio.TimeoutError) as exc:
current = query('SELECT status FROM economic_catalog_jobs WHERE id=%s', (job['id'],), one=True)
status_code = getattr(exc, 'status', 503)
ambiguous = current['status'] == 'uncertain' and status_code >= 500
state = 'uncertain' if ambiguous else ('retry' if status_code in (423,429,500,502,503,504) and job['attempts'] < 4 else 'failed')
delay = max(getattr(exc, 'retry_after', 0), 30 * 2 ** min(job['attempts'], 8) + random.randint(0, 10))
message = str(exc) if isinstance(exc, RemoteError) else 'Forbindelsen blev afbrudt; resultatet skal kontrolleres'
query("UPDATE economic_catalog_jobs SET status=%s,error=%s,next_attempt_at=now()+%s*interval '1 second',updated_at=now() WHERE id=%s", (state, message, delay, job['id']))
if status_code in (401, 403):
query('UPDATE economic_catalog_connections SET enabled=false,auto_sync=false WHERE id=%s', (job['connection_id'],))
except Exception:
logger.exception('Catalog job failed: %s', job['id'])
query("UPDATE economic_catalog_jobs SET status=CASE WHEN status='uncertain' THEN status ELSE 'failed' END,error='Intern fejl; se korrelations-id',updated_at=now() WHERE id=%s", (job['id'],))
def decimal(value):
try:
number = Decimal(str(value))
if not number.is_finite():
raise ValueError()
return number
except (InvalidOperation, ValueError, TypeError):
raise HTTPException(422, 'Ugyldigt beløb')
def calculate_price(product, rules, customer_id=None, currency='DKK', manual=None):
"""Pure calculation; never changes saved document lines."""
source, rule_id, rule_version = 'hub_standard', None, None
base = product.get('sales_price') if product.get('sales_currency', 'DKK') == currency else None
if base is None and product.get('economic_currency') == currency:
base, source = product.get('economic_sales_price_reference'), 'economic_reference'
eligible = []
today = date.today()
for rule in rules:
if not rule.get('active', True) or rule['currency'] != currency:
continue
if rule.get('valid_from') and rule['valid_from'] > today or rule.get('valid_to') and rule['valid_to'] < today:
continue
if rule.get('customer_id') and rule['customer_id'] != customer_id:
continue
if rule.get('product_id') and rule['product_id'] != product['id']:
continue
if rule.get('category_id') and rule['category_id'] not in (product.get('category_id'), product.get('subcategory_id')):
continue
rank = 4 if rule.get('customer_id') and rule.get('product_id') else 3 if rule.get('customer_id') else 2 if rule.get('product_id') else 1
eligible.append(((rank, rule.get('priority', 0)), rule))
eligible.sort(key=lambda item: item[0], reverse=True)
if manual is not None:
price, source = decimal(manual), 'manual'
elif eligible:
if len(eligible) > 1 and eligible[0][0] == eligible[1][0]:
raise HTTPException(409, 'Flere prisregler har samme prioritet')
rule = eligible[0][1]
value = decimal(rule['value'])
source, rule_id = rule['name'], rule['id']
rule_version = rule.get('version', 1)
if rule['kind'] == 'fixed':
price = value
elif rule['kind'] == 'discount':
if base is None:
raise HTTPException(422, 'Rabatreglen mangler en grundpris i den valgte valuta')
price = decimal(base) * (1 - value / 100)
else:
if product.get('cost_price') is None or product.get('cost_currency') != currency:
raise HTTPException(422, 'Tillæg kræver kost i samme valuta')
price = decimal(product['cost_price']) * (1 + value / 100)
else:
if base is None:
raise HTTPException(422, 'Ingen pris i den valgte valuta')
price = decimal(base)
if price < 0:
raise HTTPException(422, 'Prisen må ikke være negativ')
return {'unit_price': str(price.quantize(Decimal('.01'), rounding=ROUND_HALF_UP)),
'currency': currency, 'source': source, 'rule_id': rule_id, 'rule_version': rule_version,
'product_id': product['id'], 'economic_product_number': product.get('economic_product_number'),
'name': product['name'], 'calculated_at': today.isoformat()}

View File

@ -0,0 +1,255 @@
"""Catalog-aware draft exports. Uncertain POSTs are never repeated automatically."""
import asyncio
import hashlib
import json
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from urllib.parse import quote
import aiohttp
from fastapi import HTTPException
from psycopg2.extras import Json
from app.products.backend.economic_catalog import EconomicClient, RemoteError, decimal, query, transaction
from app.core.config import settings
def active_connection():
present = query("SELECT to_regclass('economic_catalog_connections') AS present", one=True)
if not present['present']:
return None
connections = query('SELECT * FROM economic_catalog_connections WHERE enabled ORDER BY id')
if len(connections) > 1:
raise HTTPException(409, 'Kun én e-conomic-aftale må være aktiv for eksport')
return connections[0] if connections else None
def amount(value):
return decimal(value).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
def external_reference(export_row_id, external_id=None):
"""Use the saved Hub order id in e-conomic, with the legacy marker as fallback."""
return str(external_id)[:100] if external_id is not None else 'BMC-HUB:' + str(export_row_id)
def line_snapshot(line, product, connection_id, currency):
if not product or not product.get('economic_product_number') or product.get('economic_connection_id') != connection_id:
raise HTTPException(409, 'Varen mangler en verificeret kobling. Åbn Varer og e-conomic for at knytte eller oprette varen.')
previous = line.get('document_snapshot') or {}
if previous.get('economic_product_number') and (previous['economic_product_number'] != product['economic_product_number'] or previous.get('economic_connection_id') != connection_id):
raise HTTPException(409, 'Varens kobling er ændret siden kladden blev gemt. Opret en ny dokumentrevision; historikken ændres ikke.')
if product.get('deleted_at') or product.get('status') != 'active' or product.get('lifecycle_status') in ('inactive', 'phasing_out') or product.get('is_active_in_economic') is False:
raise HTTPException(409, f"Varen {product['name']} er inaktiv eller spærret")
if line.get('currency', 'DKK') != currency:
raise HTTPException(409, 'Linjevaluta og dokumentvaluta er forskellige; ingen automatisk ommærkning er tilladt')
quantity, price, discount = amount(line.get('quantity')), amount(line.get('unit_price')), amount(line.get('discount_percentage', 0))
if quantity <= 0 or price < 0 or not 0 <= discount <= 100:
raise HTTPException(422, 'Ugyldig mængde, pris eller rabat')
return {'product_id': product['id'], 'economic_connection_id': connection_id,
'economic_product_number': product['economic_product_number'], 'name': product['name'],
'description': line.get('description') or product['name'], 'quantity': str(quantity),
'unit_price': str(price), 'discount_percentage': str(discount), 'currency': currency,
'unit_number': product.get('economic_unit_number'),
'net_total': str(amount(quantity * price * (1 - discount / 100))),
'price_source': line.get('price_source', 'saved_line'),
'price_rule_version': line.get('price_rule_version'), 'captured_at': date.today().isoformat()}
async def preflight(connection, customer_id, lines, layout_number=None, currency='DKK', notes=None):
client = EconomicClient()
await client.verify(connection['id'])
customer = query('SELECT id,name,economic_customer_number FROM customers WHERE id=%s', (customer_id,), one=True)
if not customer or not customer['economic_customer_number']:
raise HTTPException(409, 'Kunden mangler e-conomic-debitornummer')
remote = await client.request('GET', f"customers/{int(customer['economic_customer_number'])}")
if remote.get('barred'):
raise HTTPException(409, 'Kunden er spærret i e-conomic')
payment = (remote.get('paymentTerms') or {}).get('paymentTermsNumber')
vat = (remote.get('vatZone') or {}).get('vatZoneNumber')
layout = layout_number or (remote.get('layout') or {}).get('layoutNumber')
if not payment or not vat or not layout:
raise HTTPException(409, 'Kunden mangler betalingsbetingelser, momszone eller layout')
await client.request('GET', f'layouts/{int(layout)}')
terms = await client.request('GET', f'payment-terms/{int(payment)}')
if str(terms.get('paymentTermsType', '')).lower() == 'duedate':
raise HTTPException(409, 'Betalingsbetingelsen kræver en eksplicit forfaldsdato; vælg en kreditbetingelse før eksport')
await client.request('GET', f'vat-zones/{int(vat)}')
await client.request('GET', 'currencies/' + quote(currency, safe=''))
snapshots, payload_lines, verified = [], [], {}
for line in lines:
if not line.get('selected', True):
continue
product = query('SELECT * FROM products WHERE id=%s', (line.get('product_id'),), one=True)
snap = line_snapshot(line, product, connection['id'], currency)
number = snap['economic_product_number']
if number not in verified:
external = await client.request('GET', 'products/' + quote(number, safe=''))
if external.get('productNumber') != number or external.get('barred'):
raise HTTPException(409, f'Varen {number} mangler eller er spærret i e-conomic')
group = (external.get('productGroup') or {}).get('productGroupNumber')
if not group:
raise HTTPException(409, f'Varen {number} mangler varegruppe')
await client.request('GET', f'product-groups/{int(group)}')
unit = (external.get('unit') or {}).get('unitNumber')
if unit:
await client.request('GET', f'units/{int(unit)}')
verified[number] = external
external = verified[number]
if (external.get('unit') or {}).get('unitNumber') != snap['unit_number']:
raise HTTPException(409, f'Enheden på vare {number} er ændret; synkronisér varen først')
payload_line = {'product': {'productNumber': number}, 'description': snap['description'],
'quantity': float(snap['quantity']), 'unitNetPrice': float(snap['unit_price']),
'discountPercentage': float(snap['discount_percentage'])}
if snap['unit_number']:
payload_line['unit'] = {'unitNumber': snap['unit_number']}
snapshots.append(snap)
payload_lines.append(payload_line)
if not snapshots:
raise HTTPException(422, 'Ingen linjer valgt')
payload = {'date': date.today().isoformat(), 'currency': currency,
'customer': {'customerNumber': int(customer['economic_customer_number'])},
'paymentTerms': {'paymentTermsNumber': int(payment)}, 'layout': {'layoutNumber': int(layout)},
'recipient': {k: str(remote.get(k) or '') for k in ('name','address','zip','city','country')},
'lines': payload_lines}
payload['recipient']['vatZone'] = {'vatZoneNumber': int(vat)}
if notes:
payload['notes'] = {'textLine1': str(notes)[:1000]}
return {'payload': payload, 'lines': snapshots, 'customer_id': customer_id}
def result_for(row):
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
snapshot = row.get('snapshot') or {}
payload = snapshot.get('payload') or {}
response = row.get('response') or {}
return {'success': True, 'dry_run': False, key: row['economic_number'],
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']),
'details': {'request_payload': payload, 'economic_response': response,
'economic_customer_number': (payload.get('customer') or {}).get('customerNumber'),
'currency': payload.get('currency'),
'layout_number': (payload.get('layout') or {}).get('layoutNumber'),
'economic_order_url': response.get('self'), 'created_product_numbers': []},
'message': 'Eksporteret til e-conomic-kladde'}
def store_success(row, response, number):
with transaction() as cur:
cur.execute("UPDATE economic_document_exports SET status='verified',response=%s,economic_number=%s,error=NULL,updated_at=now() WHERE id=%s RETURNING *", (Json(response), str(number), row['id']))
saved = cur.fetchone()
if row['document_key'].startswith('order-draft:') and row['document_key'].split(':')[-1].isdigit():
column = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
cur.execute(f"UPDATE ordre_drafts SET {column}=%s,sync_status='exported',last_exported_at=now(),updated_at=now() WHERE id=%s", (str(number), int(row['document_key'].split(':')[-1])))
return saved
async def export_document(connection, kind, document_key, customer_id, lines, layout_number=None,
currency='DKK', notes=None, user_id=None, module='orders', external_id=None):
if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: ekstern skrivning er slået fra')
if module == 'timetracking' and (settings.TIMETRACKING_ECONOMIC_READ_ONLY or settings.TIMETRACKING_ECONOMIC_DRY_RUN):
raise HTTPException(409, 'Tidsmodulets safety mode blokerer ekstern skrivning')
if kind == 'order' and module == 'orders':
from app.modules.orders.backend.economic_export import ordre_economic_export_service
if ordre_economic_export_service.read_only or ordre_economic_export_service.dry_run:
raise HTTPException(409, 'Ordremodulets safety mode blokerer ekstern skrivning')
# Preserve the identity of the saved local document, even if a caller changes its contents.
previous = query('SELECT * FROM economic_document_exports WHERE connection_id=%s AND kind=%s AND document_key=%s',
(connection['id'], kind, document_key), one=True)
if previous:
if previous['status'] == 'verified':
return result_for(previous)
raise HTTPException(409, f"Eksporten er {previous['status']}. Afklar det eksisterende forsøg før et nyt.")
snapshot = await preflight(connection, customer_id, lines, layout_number, currency, notes)
with transaction() as cur:
cur.execute('''INSERT INTO economic_document_exports(connection_id,kind,document_key,snapshot,created_by)
VALUES(%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING RETURNING *''',
(connection['id'], kind, document_key, Json(snapshot), user_id))
row = cur.fetchone()
if not row:
raise HTTPException(409, 'Eksporten behandles allerede')
marker = external_reference(row['id'], external_id)
payload = snapshot['payload']
payload['references'] = {'other': marker}
snapshot['payload'] = payload
query("UPDATE economic_document_exports SET snapshot=%s,status='uncertain',updated_at=now() WHERE id=%s", (Json(snapshot), row['id']))
try:
response = await EconomicClient().request('POST', 'orders/drafts' if kind == 'order' else 'invoices/drafts', payload)
number = response.get('orderNumber') or response.get('draftOrderNumber') if kind == 'order' else response.get('draftInvoiceNumber')
if not number:
raise RemoteError(502, 'Eksportsvaret mangler dokumentnummer; afklar før nyt forsøg')
saved = store_success(row, response, number)
return result_for(saved)
except RemoteError as exc:
# Only definitive client rejection permits a later, explicit retry.
rejected = 400 <= exc.status < 500 and exc.status not in (408,)
query('UPDATE economic_document_exports SET status=%s,error=%s,updated_at=now() WHERE id=%s',
('rejected' if rejected else 'uncertain', str(exc), row['id']))
raise HTTPException(409 if rejected else 502, str(exc)) from exc
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
query("UPDATE economic_document_exports SET error='Timeout: kontrollér ekstern kladde før nyt forsøg' WHERE id=%s", (row['id'],))
raise HTTPException(502, 'Eksportresultatet er uafklaret. Brug Afstem under Varer og e-conomic; send ikke igen.') from exc
async def reconcile(export_id):
row = query('SELECT * FROM economic_document_exports WHERE id=%s', (export_id,), one=True)
if not row:
raise HTTPException(404, 'Eksportforsøg findes ikke')
if row['status'] == 'verified':
return result_for(row)
client = EconomicClient()
await client.verify(row['connection_id'])
if row['kind'] != 'order':
raise HTTPException(403, 'Hub må kun afstemme ordrekladder')
path = 'orders/drafts'
expected = row['snapshot']['payload']
marker = (expected.get('references') or {}).get('other') or external_reference(row['id'])
matches = [r for r in await client.collection(path) if (r.get('references') or {}).get('other') == marker]
if len(matches) != 1:
raise HTTPException(409, f'Fandt {len(matches)} kladder med den præcise Hub-reference. Ingen ny eksport tilladt; kontrollér også bogførte/slettede dokumenter i e-conomic.')
match = matches[0]
number = match.get('orderNumber') or match.get('draftOrderNumber') if row['kind'] == 'order' else match.get('draftInvoiceNumber')
if not number:
raise HTTPException(409, 'Matchet mangler dokumentnummer')
remote = await client.request('GET', path + '/' + str(int(number)))
if (remote.get('references') or {}).get('other') != marker or remote.get('currency') != expected['currency'] or (remote.get('customer') or {}).get('customerNumber') != expected['customer']['customerNumber']:
raise HTTPException(409, 'Dokumentets kunde, valuta eller reference afviger')
actual_lines = remote.get('lines', [])
if len(actual_lines) != len(expected['lines']) or any(
(a.get('product') or {}).get('productNumber') != e['product']['productNumber'] or
any(decimal(a.get(k, 0)) != decimal(e.get(k, 0)) for k in ('quantity','unitNetPrice','discountPercentage'))
for a, e in zip(actual_lines, expected['lines'])
):
raise HTTPException(409, 'Dokumentets linjer afviger fra det gemte snapshot')
saved = store_success(row, remote, number)
return result_for(saved)
def unsaved_key(customer_id, lines, notes, layout):
"""Legacy callers without a saved draft cannot bypass duplicate protection by clicking twice."""
data = json.dumps([customer_id, lines, notes, layout], sort_keys=True, default=str)
return 'unsaved:' + hashlib.sha256(data.encode()).hexdigest()
def snapshot_draft_lines(lines):
"""Add identity/price evidence without recalculating or requiring an external link."""
saved = []
for original in lines:
line = dict(original)
p = query('SELECT id,name,economic_connection_id,economic_product_number,economic_unit_number FROM products WHERE id=%s', (line.get('product_id'),), one=True) if line.get('product_id') else None
previous = line.get('document_snapshot') or {}
identity = {'product_id': p['id'] if p else None, 'name': p['name'] if p else line.get('description'),
'economic_connection_id': p['economic_connection_id'] if p else None,
'economic_product_number': p['economic_product_number'] if p else None,
'unit_number': p['economic_unit_number'] if p else None,
'unit_price': str(decimal(line.get('unit_price', 0))),
'quantity': str(decimal(line.get('quantity', 1))),
'discount_percentage': str(decimal(line.get('discount_percentage', 0))),
'currency': line.get('currency', 'DKK'), 'description': line.get('description')}
if {k:v for k,v in previous.items() if k != 'captured_at'} != identity:
identity['captured_at'] = date.today().isoformat()
line['document_snapshot'] = identity
saved.append(line)
return saved

View File

@ -0,0 +1,77 @@
"""Explicit repricing of local drafts; saved contracts are never auto-repriced."""
import hashlib
import json
from fastapi import HTTPException
from psycopg2.extras import Json
from app.products.backend.economic_catalog import calculate_price, decimal, query, transaction
def validate_manual_prices(lines, customer_id, can_override, saved_lines=None):
"""Accept preserved agreed prices; new deviations require the dedicated permission."""
if can_override:
return
saved = {str(line.get('line_key')): line for line in (saved_lines or []) if line.get('line_key')}
rules = query('SELECT * FROM economic_price_rules WHERE active')
for line in lines:
old = saved.get(str(line.get('line_key')))
if old and old.get('product_id') == line.get('product_id') and all(
decimal(old.get(key, 0)) == decimal(line.get(key, 0)) for key in ('unit_price', 'discount_percentage')
) and old.get('currency', 'DKK') == line.get('currency', 'DKK'):
continue
product = query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (line.get('product_id'),), one=True)
if not product:
raise HTTPException(403, 'Vælg en vare med beregnet pris, eller få rettighed til manuel pris')
calculated = calculate_price(product, rules, customer_id, line.get('currency', 'DKK'))
if decimal(calculated['unit_price']) != decimal(line.get('unit_price', 0)) or decimal(line.get('discount_percentage', 0)) != 0:
raise HTTPException(403, 'Prisen/rabatten afviger fra den beregnede pris. Manuel pris kræver særskilt rettighed.')
def proposals(draft):
lines = draft.get('lines_json') or []
if isinstance(lines, str):
lines = json.loads(lines)
rules = query('SELECT * FROM economic_price_rules WHERE active')
result = []
for index, line in enumerate(lines):
if not line.get('product_id') or line.get('source_type') == 'subscription':
# The existing subscription price is an agreed contract price.
continue
p = query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (line['product_id'],), one=True)
if not p:
raise HTTPException(409, f'Produktet på linje {index+1} findes ikke')
price = calculate_price(p, rules, draft['customer_id'], line.get('currency', 'DKK'))
result.append({'index': index, 'description': line.get('description', p['name']),
'before': str(line.get('unit_price')), 'after': price['unit_price'], 'snapshot': price})
token = hashlib.sha256(json.dumps([draft['id'], lines, result], sort_keys=True, default=str).encode()).hexdigest()
return {'changes': result, 'token': token, 'subscription_prices_preserved': True}
def reprice(draft_id, token=None, user_id=None):
with transaction() as cur:
cur.execute('SELECT * FROM ordre_drafts WHERE id=%s FOR UPDATE', (draft_id,))
draft = cur.fetchone()
if not draft:
raise HTTPException(404, 'Ordrekladde ikke fundet')
if draft.get('sync_status') in ('exported','posted','paid'):
raise HTTPException(409, 'Eksporterede/bogførte dokumenter må ikke genberegnes')
cur.execute("SELECT id FROM economic_document_exports WHERE document_key=%s AND status NOT IN ('rejected','archived')", (f'order-draft:{draft_id}',))
if cur.fetchone():
raise HTTPException(409, 'Dokumentet har en afsendt eller uafklaret eksport')
preview = proposals(draft)
if token is None:
return preview
if token != preview['token']:
raise HTTPException(409, 'Priser eller linjer er ændret siden preview. Kontrollér priserne igen.')
lines = draft['lines_json'] if isinstance(draft['lines_json'], list) else json.loads(draft['lines_json'])
for change in preview['changes']:
line = lines[change['index']]
line['unit_price'] = float(change['after'])
line['price_snapshot'] = change['snapshot']
line['price_source'] = change['snapshot']['source']
line['price_rule_version'] = change['snapshot']['rule_version']
line['amount'] = float(decimal(line.get('quantity', 1)) * decimal(change['after']) * (1-decimal(line.get('discount_percentage', 0))/100))
cur.execute('UPDATE ordre_drafts SET lines_json=%s,updated_at=now() WHERE id=%s', (Json(lines), draft_id))
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('order',%s,'reprice',%s,%s)", (str(draft_id), user_id, Json(preview)))
return {'saved': True, **preview}

View File

@ -0,0 +1,579 @@
"""Catalog administration uses existing Hub authentication and product records."""
from typing import Any, Dict, Optional
from datetime import date
from decimal import Decimal
from uuid import UUID
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.routing import APIRoute
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.auth_dependencies import require_permission
from app.core.config import settings
from app.core.economic_write_policy import approved_four_eyes_write
from app.products.backend import economic_catalog as catalog
class CatalogRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def wrapped(request):
try:
return await handler(request)
except catalog.RemoteError as exc:
raise HTTPException(409 if exc.status < 500 else 502, str(exc)) from exc
return wrapped
router = APIRouter(route_class=CatalogRoute)
views = APIRouter()
read = require_permission('economic.catalog.view')
manage = require_permission('economic.catalog.manage')
class ConnectionInput(BaseModel):
agreement_number: str = Field(min_length=1, max_length=30)
name: str = Field(min_length=1, max_length=100)
currency: str = Field(pattern='^[A-Z]{3}$')
class ConfigInput(BaseModel):
enabled: bool = False
auto_sync: bool = False
sync_interval_minutes: int = Field(default=15, ge=5, le=1440)
number_prefix: str = Field(default='BMC-', min_length=1, max_length=10, pattern='^[A-Za-z0-9-]+$')
time_unit_number: Optional[int] = Field(default=None, gt=0)
class LinkInput(BaseModel):
connection_id: int
number: str = Field(min_length=1, max_length=25)
class CreateInput(BaseModel):
connection_id: int
group_number: int = Field(gt=0)
unit_number: Optional[int] = Field(default=None, gt=0)
class RuleInput(BaseModel):
name: str = Field(min_length=1, max_length=100)
product_id: Optional[int] = None
customer_id: Optional[int] = None
category_id: Optional[int] = None
kind: str = Field(pattern='^(fixed|discount|cost_markup)$')
value: Decimal = Field(ge=0, allow_inf_nan=False)
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
priority: int = 0
valid_from: Optional[date] = None
valid_to: Optional[date] = None
class PriceInput(BaseModel):
product_id: int
customer_id: Optional[int] = None
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
class StandardPriceInput(BaseModel):
sales_price: str
sales_currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
cost_currency: Optional[str] = Field(default=None, pattern='^[A-Z]{3}$')
class StateInput(BaseModel):
lifecycle_status: str = Field(pattern='^(draft|active|phasing_out|inactive)$')
category_id: Optional[int] = None
subcategory_id: Optional[int] = None
class EconomicChangeInput(BaseModel):
connection_id: int
target_type: str = Field(pattern='^(customer|product)$')
target_number: str = Field(min_length=1, max_length=25)
changes: Dict[str, Any]
reason: str = Field(min_length=10, max_length=500)
class EconomicReviewInput(BaseModel):
note: Optional[str] = Field(default=None, max_length=500)
EDITABLE_ECONOMIC_FIELDS = {
'product': {'name', 'salesPrice', 'barred', 'productGroup', 'unit'},
'customer': {'name', 'address', 'zip', 'city', 'email', 'telephoneAndFaxNumber',
'corporateIdentificationNumber', 'currency', 'customerGroup',
'paymentTerms', 'vatZone'},
}
def _economic_target_path(target_type: str, target_number: str) -> str:
collection = 'customers' if target_type == 'customer' else 'products'
return f'{collection}/{quote(str(target_number).strip(), safe="")}'
def _editable_snapshot(target_type: str, source: dict) -> dict:
return {key: source.get(key) for key in EDITABLE_ECONOMIC_FIELDS[target_type] if key in source}
def _validate_change_fields(target_type: str, changes: dict) -> dict:
if not changes:
raise HTTPException(422, 'Vælg mindst ét felt, der skal ændres')
unknown = set(changes) - EDITABLE_ECONOMIC_FIELDS[target_type]
if unknown:
raise HTTPException(422, 'Feltet kan ikke ændres via Hub: ' + ', '.join(sorted(unknown)))
if 'name' in changes and not str(changes['name']).strip():
raise HTTPException(422, 'Navn må ikke være tomt')
return changes
@router.patch('/products/{product_id}/economic-state')
async def product_state(product_id: int, payload: StateInput, user=Depends(manage)):
with catalog.transaction() as cur:
cur.execute('SELECT lifecycle_status,category_id,subcategory_id FROM products WHERE id=%s FOR UPDATE', (product_id,))
old = cur.fetchone()
if not old:
raise HTTPException(404, 'Vare ikke fundet')
cur.execute('UPDATE products SET lifecycle_status=%s,category_id=%s,subcategory_id=%s,updated_by=%s WHERE id=%s', (payload.lifecycle_status,payload.category_id,payload.subcategory_id,user['id'],product_id))
catalog.audit(cur, product_id, 'economic_internal_state', {'old': dict(old), 'new': payload.model_dump()}, user['id'])
return {'saved': True}
@router.patch('/products/{product_id}/economic-pricing')
async def standard_price(product_id: int, payload: StandardPriceInput, user=Depends(require_permission('economic.pricing.manage'))):
value = catalog.decimal(payload.sales_price)
if value < 0:
raise HTTPException(422, 'Prisen må ikke være negativ')
with catalog.transaction() as cur:
cur.execute('SELECT sales_price,sales_currency FROM products WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (product_id,))
old = cur.fetchone()
if not old:
raise HTTPException(404, 'Vare ikke fundet')
cur.execute('UPDATE products SET sales_price=%s,sales_currency=%s,cost_currency=COALESCE(%s,cost_currency),updated_by=%s WHERE id=%s', (value, payload.sales_currency, payload.cost_currency, user['id'], product_id))
cur.execute("INSERT INTO product_price_history(product_id,price_type,old_price,new_price,note,changed_by) VALUES(%s,'sales_price',%s,%s,%s,%s)", (product_id, old['sales_price'], value, f"Valuta {old['sales_currency']}{payload.sales_currency}", user['id']))
catalog.audit(cur, product_id, 'economic_hub_price', {'old': str(old['sales_price']), 'new': str(value), 'currency': payload.sales_currency}, user['id'])
return {'saved': True}
@views.get('/settings/economic-products')
async def page(request: Request, user=Depends(read)):
return Jinja2Templates(directory='app').TemplateResponse('products/frontend/economic.html', {'request': request})
@router.get('/economic/catalog/status')
async def status(user=Depends(read)):
from app.core.auth_service import AuthService
from app.modules.orders.backend.economic_export import ordre_economic_export_service
permissions = {name: bool(user.get('is_superadmin') or AuthService.user_has_permission(user['id'], name)) for name in (
'economic.catalog.manage', 'economic.catalog.rebind', 'economic.pricing.manage', 'economic.documents.export',
'economic.changes.request', 'economic.changes.approve', 'products.update')}
return {'connections': catalog.query('SELECT * FROM economic_catalog_connections ORDER BY id'),
'permissions': permissions,
'read_only': settings.ECONOMIC_READ_ONLY, 'dry_run': settings.ECONOMIC_DRY_RUN,
'order_read_only': ordre_economic_export_service.read_only,
'order_dry_run': ordre_economic_export_service.dry_run,
'time_read_only': settings.TIMETRACKING_ECONOMIC_READ_ONLY,
'time_dry_run': settings.TIMETRACKING_ECONOMIC_DRY_RUN,
'jobs': catalog.query('SELECT id,kind,status,error,result,product_id,created_at,attempts FROM economic_catalog_jobs ORDER BY created_at DESC LIMIT 100'),
'exports': catalog.query('SELECT id,kind,status,economic_number,error,created_at FROM economic_document_exports ORDER BY created_at DESC LIMIT 100'),
'change_requests': catalog.query('''SELECT r.*, requester.full_name AS requester_name,
reviewer.full_name AS reviewer_name FROM economic_change_requests r
LEFT JOIN users requester ON requester.user_id=r.requested_by_user_id
LEFT JOIN users reviewer ON reviewer.user_id=r.reviewed_by_user_id
ORDER BY r.requested_at DESC LIMIT 100''')}
@router.post('/economic/catalog/connections')
async def connect(payload: ConnectionInput, user=Depends(manage)):
remote = await catalog.EconomicClient().request('GET', 'self')
actual = str(remote.get('agreementNumber') or '')
if actual != payload.agreement_number:
raise HTTPException(409, 'Aftalen matcher ikke de konfigurerede credentials')
if payload.currency != (remote.get('settings') or {}).get('baseCurrency'):
raise HTTPException(409, 'Valutaen skal svare til aftalens basisvaluta')
return catalog.query('''INSERT INTO economic_catalog_connections(agreement_number,name,currency)
VALUES(%s,%s,%s) ON CONFLICT(agreement_number) DO UPDATE SET name=EXCLUDED.name RETURNING *''',
(actual, payload.name, payload.currency), one=True)
@router.get('/economic/catalog/identity')
async def identity(user=Depends(manage)):
remote = await catalog.EconomicClient().request('GET', 'self')
return {'agreement_number':str(remote.get('agreementNumber') or ''),
'name':(remote.get('company') or {}).get('name') or 'e-conomic',
'currency':(remote.get('settings') or {}).get('baseCurrency') or ''}
@router.patch('/economic/catalog/connections/{connection_id}')
async def configure(connection_id: int, payload: ConfigInput, user=Depends(manage)):
await catalog.EconomicClient().verify(connection_id)
if payload.time_unit_number:
await catalog.EconomicClient().request('GET', f'units/{payload.time_unit_number}')
return catalog.query('''UPDATE economic_catalog_connections SET enabled=%s,auto_sync=%s,
sync_interval_minutes=%s,number_prefix=%s,time_unit_number=%s WHERE id=%s RETURNING *''',
(payload.enabled, payload.auto_sync, payload.sync_interval_minutes, payload.number_prefix, payload.time_unit_number, connection_id), one=True)
@router.post('/economic/catalog/connections/{connection_id}/sync')
async def sync(connection_id: int, preview: bool = True, user=Depends(manage)):
await catalog.EconomicClient().verify(connection_id)
return catalog.enqueue(connection_id, 'preview' if preview else 'import', user['id'])
@router.get('/economic/catalog/products')
async def products(q: str = '', offset: int = Query(0, ge=0), include_inactive: bool = False, user=Depends(read)):
# Deliberate field selection: supplier cost and remote payload are not returned.
return catalog.query('''SELECT id,name,sku_internal,er_number,economic_product_number,
economic_connection_id,economic_product_group_number,economic_unit_number,sales_price,
sales_currency,economic_sales_price_reference,economic_currency,status,lifecycle_status,
is_active_in_economic,economic_sync_status,economic_last_synced_at,category_id,subcategory_id
FROM products WHERE deleted_at IS NULL AND (%s OR (status='active' AND lifecycle_status IN ('active','draft') AND is_active_in_economic IS DISTINCT FROM false))
AND (name ILIKE %s OR economic_product_number ILIKE %s OR sku_internal ILIKE %s)
ORDER BY name,id LIMIT 100 OFFSET %s''', (include_inactive, '%'+q+'%', '%'+q+'%', '%'+q+'%', offset))
@router.get('/economic/catalog/connections/{connection_id}/references')
async def references(connection_id: int, user=Depends(read)):
return catalog.query('SELECT connection_id,kind,number,name,category_id,allow_new,phasing_out FROM economic_catalog_references WHERE connection_id=%s ORDER BY kind,name', (connection_id,))
class MappingInput(BaseModel):
category_id: Optional[int] = None
allow_new: bool = True
phasing_out: bool = False
@router.patch('/economic/catalog/connections/{connection_id}/groups/{number}')
async def group_mapping(connection_id: int, number: int, payload: MappingInput, user=Depends(manage)):
with catalog.transaction() as cur:
cur.execute("SELECT category_id,allow_new,phasing_out FROM economic_catalog_references WHERE connection_id=%s AND kind='product-groups' AND number=%s FOR UPDATE", (connection_id, number))
old = cur.fetchone()
if not old:
raise HTTPException(404, 'Varegruppen er ikke importeret')
cur.execute("UPDATE economic_catalog_references SET category_id=%s,allow_new=%s,phasing_out=%s WHERE connection_id=%s AND kind='product-groups' AND number=%s", (payload.category_id,payload.allow_new,payload.phasing_out,connection_id,number))
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('group',%s,'mapping',%s,%s)", (f'{connection_id}:{number}',user['id'],Json({'old':dict(old),'new':payload.model_dump()})))
return {'saved': True}
@router.post('/products/{product_id}/economic-link')
async def link(product_id: int, payload: LinkInput, user=Depends(manage)):
return await catalog.link_product(product_id, payload.connection_id, payload.number, user['id'])
class RebindInput(LinkInput):
reason: str = Field(min_length=10, max_length=500)
confirmation_number: str
@router.post('/products/{product_id}/economic-rebind')
async def rebind(product_id: int, payload: RebindInput, user=Depends(require_permission('economic.catalog.rebind'))):
from urllib.parse import quote
number = catalog.product_number(payload.number)
client = catalog.EconomicClient()
await client.verify(payload.connection_id)
remote = await client.request('GET', 'products/' + quote(number, safe=''))
if remote.get('productNumber') != number:
raise HTTPException(409, 'Det eksterne varenummer afviger')
with catalog.transaction() as cur:
cur.execute('SELECT pg_try_advisory_xact_lock(24201) AS acquired')
if not cur.fetchone()['acquired']:
raise HTTPException(409, 'Synkronisering kører. Prøv omkobling, når den er færdig.')
cur.execute('SELECT * FROM products WHERE id=%s FOR UPDATE', (product_id,))
old = cur.fetchone()
if not old or not old['economic_product_number'] or old['economic_product_number'] != payload.confirmation_number:
raise HTTPException(409, 'Bekræft det nuværende varenummer præcist')
cur.execute("SELECT id FROM economic_catalog_jobs WHERE product_id=%s AND status IN ('running','uncertain')", (product_id,))
if cur.fetchone():
raise HTTPException(409, 'Varen har et uafklaret eller igangværende job')
cur.execute('SELECT id FROM products WHERE economic_connection_id=%s AND economic_product_number=%s AND id<>%s', (payload.connection_id, number, product_id))
if cur.fetchone():
raise HTTPException(409, 'Målet er allerede koblet til et andet Hub-produkt')
cur.execute("SELECT set_config('bmc.economic_rebind',%s,true)", (str(product_id),))
cur.execute("UPDATE economic_catalog_jobs SET status='cancelled',error='Administrativ omkobling' WHERE product_id=%s AND status IN ('pending','retry','failed')", (product_id,))
cur.execute('''UPDATE products SET economic_connection_id=%s,economic_product_number=%s,
economic_name_version=economic_name_version+1,economic_snapshot=%s,economic_sync_status='unlinked',updated_by=%s WHERE id=%s''',
(payload.connection_id, number, Json(remote), user['id'], product_id))
catalog.audit(cur, product_id, 'economic_rebind', {'old_connection': old['economic_connection_id'],
'old_number': old['economic_product_number'], 'new_connection':payload.connection_id,
'new_number':number,'reason':payload.reason}, user['id'])
connection = catalog.query('SELECT * FROM economic_catalog_connections WHERE id=%s', (payload.connection_id,), one=True)
catalog.import_product(connection, remote, user['id'])
return {'rebound': True, 'historical_snapshots_unchanged': True}
@router.post('/products/{product_id}/economic-create')
async def create(product_id: int, payload: CreateInput, user=Depends(manage)):
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: vareoprettelse er slået fra')
await catalog.EconomicClient().verify(payload.connection_id)
data = {'productGroup': {'productGroupNumber': payload.group_number}}
if payload.unit_number:
data['unit'] = {'unitNumber': payload.unit_number}
return catalog.enqueue(payload.connection_id, 'create', user['id'], product_id, data)
class ResolveInput(BaseModel):
choice: str = Field(pattern='^economic$')
@router.post('/products/{product_id}/economic-resolve')
async def resolve(product_id: int, payload: ResolveInput, user=Depends(manage)):
product = catalog.query('SELECT * FROM products WHERE id=%s', (product_id,), one=True)
if not product or not product['economic_product_number']:
raise HTTPException(404, 'Koblet vare ikke fundet')
client = catalog.EconomicClient()
await client.verify(product['economic_connection_id'])
from urllib.parse import quote
remote = await client.request('GET', 'products/' + quote(product['economic_product_number'], safe=''))
with catalog.transaction() as cur:
cur.execute('SELECT * FROM products WHERE id=%s FOR UPDATE', (product_id,))
product = cur.fetchone()
if payload.choice == 'economic':
cur.execute("UPDATE products SET name=%s,economic_snapshot=%s,economic_name_version=economic_name_version+1,economic_sync_status='synced',updated_by=%s WHERE id=%s", (remote['name'], Json(remote), user['id'], product_id))
else:
raise HTTPException(403, 'Hub må ikke ændre eksisterende e-conomic-varer')
catalog.audit(cur, product_id, 'economic_conflict_resolved', {'choice': payload.choice}, user['id'])
return {'saved': True}
@router.post('/economic/catalog/jobs/{job_id}/retry')
async def retry(job_id: UUID, user=Depends(manage)):
job = catalog.query('SELECT * FROM economic_catalog_jobs WHERE id=%s', (str(job_id),), one=True)
if not job or job['status'] not in ('failed','retry'):
raise HTTPException(409, 'Jobbet kan ikke gentages; uafklarede oprettelser skal kontrolleres manuelt')
if job['kind'] == 'create':
raise HTTPException(409, 'Opret et nyt forsøg fra varen; det gamle nummer må ikke genbruges blindt')
catalog.query("UPDATE economic_catalog_jobs SET status='pending',attempts=0,next_attempt_at=now() WHERE id=%s", (str(job_id),))
return {'queued': True}
@router.get('/economic/catalog/price-rules')
async def price_rules(user=Depends(require_permission('economic.pricing.manage'))):
return catalog.query('SELECT * FROM economic_price_rules ORDER BY priority DESC,id')
@router.post('/economic/catalog/price-rules')
async def add_rule(payload: RuleInput, user=Depends(require_permission('economic.pricing.manage'))):
if payload.kind == 'discount' and payload.value > 100 or payload.valid_from and payload.valid_to and payload.valid_to < payload.valid_from:
raise HTTPException(422, 'Ugyldig rabat eller gyldighedsperiode')
with catalog.transaction() as cur:
cur.execute('''INSERT INTO economic_price_rules(name,product_id,customer_id,category_id,kind,value,currency,priority,valid_from,valid_to,created_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''',
(payload.name,payload.product_id,payload.customer_id,payload.category_id,payload.kind,payload.value,payload.currency,payload.priority,payload.valid_from,payload.valid_to,user['id']))
created = cur.fetchone()
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('price_rule',%s,'created',%s,%s)", (str(created['id']), user['id'], Json(payload.model_dump(mode='json'))))
return created
@router.delete('/economic/catalog/price-rules/{rule_id}')
async def disable_rule(rule_id: int, user=Depends(require_permission('economic.pricing.manage'))):
with catalog.transaction() as cur:
cur.execute('UPDATE economic_price_rules SET active=false,version=version+1 WHERE id=%s RETURNING id', (rule_id,))
if not cur.fetchone():
raise HTTPException(404, 'Prisregel ikke fundet')
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('price_rule',%s,'disabled',%s,'{}')", (str(rule_id), user['id']))
return {'disabled': True}
@router.post('/pricing/calculate')
async def price(payload: PriceInput, user=Depends(read)):
product = catalog.query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (payload.product_id,), one=True)
if not product:
raise HTTPException(404, 'Vare ikke fundet')
return catalog.calculate_price(product, catalog.query('SELECT * FROM economic_price_rules WHERE active'), payload.customer_id, payload.currency)
@router.post('/economic/catalog/jobs/{job_id}/reconcile')
async def reconcile_product(job_id: UUID, user=Depends(manage)):
from urllib.parse import quote
job = catalog.query('SELECT * FROM economic_catalog_jobs WHERE id=%s', (str(job_id),), one=True)
if not job or job['kind'] != 'create' or job['status'] != 'uncertain':
raise HTTPException(409, 'Kun uafklarede vareoprettelser kan afstemmes')
client = catalog.EconomicClient()
await client.verify(job['connection_id'])
expected = job['payload']
remote = await client.request('GET', 'products/' + quote(expected['productNumber'], safe=''))
if any(remote.get(k) != expected.get(k) for k in ('productNumber', 'name')) or (remote.get('productGroup') or {}).get('productGroupNumber') != expected['productGroup']['productGroupNumber'] or (remote.get('unit') or {}).get('unitNumber') != (expected.get('unit') or {}).get('unitNumber'):
raise HTTPException(409, 'Den eksterne vare afviger; ingen automatisk tilknytning')
product = catalog.query('SELECT economic_product_number FROM products WHERE id=%s', (job['product_id'],), one=True)
if product and product['economic_product_number'] != expected['productNumber']:
await catalog.link_product(job['product_id'], job['connection_id'], expected['productNumber'], user['id'])
catalog.query("UPDATE economic_catalog_jobs SET status='done',result=%s,error=NULL,updated_at=now() WHERE id=%s", (Json({'number': expected['productNumber'], 'reconciled_by': user['id']}), str(job_id)))
return {'reconciled': True}
@router.post('/economic/change-requests')
async def request_economic_change(
payload: EconomicChangeInput,
user=Depends(require_permission('economic.changes.request')),
):
"""Store a reviewed before/after proposal without changing e-conomic."""
changes = _validate_change_fields(payload.target_type, payload.changes)
client = catalog.EconomicClient()
await client.verify(payload.connection_id)
target_number = str(payload.target_number).strip()
path = _economic_target_path(payload.target_type, target_number)
remote = await client.request('GET', path)
before = _editable_snapshot(payload.target_type, remote)
proposed = dict(before)
proposed.update(changes)
if proposed == before:
raise HTTPException(409, 'Forslaget ændrer ingen værdier')
try:
with catalog.transaction() as cur:
cur.execute('''INSERT INTO economic_change_requests(
connection_id,target_type,target_number,requested_changes,before_snapshot,
proposed_snapshot,reason,requested_by_user_id)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''',
(payload.connection_id, payload.target_type, target_number, Json(changes),
Json(before), Json(proposed), payload.reason.strip(), user['id']))
created = cur.fetchone()
cur.execute("""INSERT INTO economic_integration_audit(
object_type,object_id,action,user_id,changes)
VALUES('change_request',%s,'requested',%s,%s)""",
(str(created['id']), user['id'], Json({'target_type': payload.target_type,
'target_number': target_number, 'before': before, 'proposed': proposed,
'reason': payload.reason.strip()})))
return created
except Exception as exc:
if getattr(exc, 'pgcode', None) == '23505':
raise HTTPException(409, 'Der findes allerede en afventende ændring til dette objekt') from exc
raise
@router.post('/economic/change-requests/{request_id}/reject')
async def reject_economic_change(
request_id: UUID,
payload: EconomicReviewInput,
user=Depends(require_permission('economic.changes.approve')),
):
with catalog.transaction() as cur:
cur.execute("""UPDATE economic_change_requests SET status='rejected',
reviewed_by_user_id=%s,reviewed_at=now(),review_note=%s
WHERE id=%s AND status='pending' AND requested_by_user_id<>%s RETURNING *""",
(user['id'], payload.note, str(request_id), user['id']))
row = cur.fetchone()
if not row:
raise HTTPException(409, 'Anmodningen kan ikke afvises, eller du har selv oprettet den')
cur.execute("""INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes)
VALUES('change_request',%s,'rejected',%s,%s)""",
(str(request_id), user['id'], Json({'note': payload.note})))
return row
@router.post('/economic/change-requests/{request_id}/approve')
async def approve_economic_change(
request_id: UUID,
payload: EconomicReviewInput,
user=Depends(require_permission('economic.changes.approve')),
):
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: ændringen kan ikke udføres i e-conomic')
with catalog.transaction() as cur:
cur.execute('SELECT * FROM economic_change_requests WHERE id=%s FOR UPDATE', (str(request_id),))
row = cur.fetchone()
if not row or row['status'] != 'pending':
raise HTTPException(409, 'Anmodningen afventer ikke længere godkendelse')
if row['requested_by_user_id'] == user['id']:
raise HTTPException(403, 'Du kan ikke godkende din egen ændring')
cur.execute("""UPDATE economic_change_requests SET status='applying',
reviewed_by_user_id=%s,reviewed_at=now(),review_note=%s WHERE id=%s""",
(user['id'], payload.note, str(request_id)))
client = catalog.EconomicClient()
path = _economic_target_path(row['target_type'], row['target_number'])
try:
await client.verify(row['connection_id'])
current = await client.request('GET', path)
current_editable = _editable_snapshot(row['target_type'], current)
if current_editable != row['before_snapshot']:
raise HTTPException(409, 'Data er ændret i e-conomic siden anmodningen. Opret et nyt forslag.')
outgoing = dict(current)
outgoing.update(row['requested_changes'])
with approved_four_eyes_write():
response = await client.request('PUT', path, outgoing)
except Exception as exc:
message = str(exc.detail if isinstance(exc, HTTPException) else exc)[:500]
catalog.query("""UPDATE economic_change_requests SET status='failed',error=%s
WHERE id=%s AND status='applying'""", (message, str(request_id)))
if isinstance(exc, HTTPException):
raise
raise
with catalog.transaction() as cur:
cur.execute("""UPDATE economic_change_requests SET status='approved',applied_at=now(),
response_snapshot=%s,error=NULL WHERE id=%s RETURNING *""",
(Json(_editable_snapshot(row['target_type'], response)), str(request_id)))
completed = cur.fetchone()
cur.execute("""INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes)
VALUES('change_request',%s,'approved_and_applied',%s,%s)""",
(str(request_id), user['id'], Json({'review_note': payload.note,
'target_type': row['target_type'], 'target_number': row['target_number']})))
return completed
class DocumentInput(BaseModel):
document_key: str = Field(min_length=1, max_length=150)
customer_id: int
lines: list[dict]
layout_number: Optional[int] = Field(default=None, gt=0)
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
notes: Optional[str] = None
@router.post('/economic/documents/{kind}/preview')
async def document_preview(kind: str, payload: DocumentInput, user=Depends(require_permission('economic.documents.export'))):
from app.products.backend import economic_documents as documents
if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
connection = documents.active_connection()
if not connection:
raise HTTPException(409, 'Aktivér først den verificerede e-conomic-forbindelse')
return await documents.preflight(connection, payload.customer_id, payload.lines, payload.layout_number, payload.currency, payload.notes)
@router.post('/economic/documents/{kind}/export')
async def document_export(kind: str, payload: DocumentInput, user=Depends(require_permission('economic.documents.export'))):
from app.products.backend import economic_documents as documents
from app.products.backend.economic_pricing import validate_manual_prices
from app.core.auth_service import AuthService
saved = None
if payload.document_key.startswith('order-draft:') and payload.document_key.split(':')[-1].isdigit():
saved = catalog.query('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (int(payload.document_key.split(':')[-1]),), one=True)
saved_lines = saved['lines_json'] if saved and saved['customer_id'] == payload.customer_id else []
validate_manual_prices(payload.lines, payload.customer_id, user.get('is_superadmin') or AuthService.user_has_permission(user['id'], 'economic.pricing.override'), saved_lines)
if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: brug preview; ingen ekstern skrivning')
connection = documents.active_connection()
if not connection:
raise HTTPException(409, 'Aktivér først den verificerede e-conomic-forbindelse')
return await documents.export_document(connection, kind, payload.document_key, payload.customer_id,
payload.lines, payload.layout_number, payload.currency, payload.notes, user['id'])
@router.post('/economic/documents/exports/{export_id}/reconcile')
async def reconcile_document(export_id: UUID, user=Depends(manage)):
from app.products.backend.economic_documents import reconcile
return await reconcile(str(export_id))
@router.post('/economic/documents/exports/{export_id}/retry')
async def retry_document(export_id: UUID, user=Depends(require_permission('economic.documents.export'))):
# Only definitively rejected attempts can release their local key. Retain their audit row.
result = catalog.query("UPDATE economic_document_exports SET document_key=document_key||':rejected:'||id::text,status='archived',updated_at=now() WHERE id=%s AND status='rejected' RETURNING id", (str(export_id),), one=True)
if not result:
raise HTTPException(409, 'Kun et definitivt afvist forsøg kan frigives. Uafklarede forsøg skal afstemmes.')
return {'retry_allowed': True}
class RepriceInput(BaseModel):
token: Optional[str] = None
@router.post('/economic/ordre-drafts/{draft_id}/reprice')
async def reprice(draft_id: int, payload: RepriceInput, user=Depends(require_permission('economic.pricing.override'))):
from app.products.backend.economic_pricing import reprice as calculate
return calculate(draft_id, payload.token, user['id'])

View File

@ -13,7 +13,8 @@ import json
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter()
from app.products.backend.economic_access import ProductAccessRoute
router = APIRouter(route_class=ProductAccessRoute)
def _apigw_headers() -> Dict[str, str]:
@ -78,9 +79,25 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_name = payload.get("supplier_name")
supplier_code = payload.get("supplier_code")
supplier_sku = payload.get("supplier_sku") or payload.get("sku")
supplier_price = payload.get("supplier_price") or payload.get("price")
supplier_price = payload.get("supplier_price") if payload.get("supplier_price") is not None else payload.get("price")
supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK"
supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty")
supplier_stock = payload.get("supplier_stock") if payload.get("supplier_stock") is not None else payload.get("stock_qty")
variant_key = str(payload.get('variant_key') or '')
license_term = str(payload.get('license_term') or '')
billing_interval = str(payload.get('billing_interval') or '')
market = str(payload.get('market') or '').upper()
valid_until = payload.get('valid_until') or None
if supplier_price is not None:
from app.products.backend.economic_catalog import decimal
supplier_price = decimal(supplier_price)
if supplier_price < 0:
raise HTTPException(422, 'Leverandørprisen må ikke være negativ')
if valid_until:
from datetime import date
try:
valid_until = date.fromisoformat(str(valid_until))
except ValueError:
raise HTTPException(422, 'Ugyldig gyldighedsdato')
supplier_url = payload.get("supplier_url") or payload.get("supplier_link")
supplier_product_url = (
payload.get("supplier_product_url")
@ -115,6 +132,8 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
existing = None
if match_query and match_params is not None:
match_query = match_query.replace('LIMIT 1', "AND COALESCE(variant_key,'')=%s AND COALESCE(license_term,'')=%s AND COALESCE(billing_interval,'')=%s AND COALESCE(market,'')=%s LIMIT 1")
match_params = (*match_params, variant_key, license_term, billing_interval, market)
existing = execute_query_single(match_query, match_params)
if existing:
@ -129,6 +148,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url = %s,
supplier_product_url = %s,
source = %s,
variant_key = %s, license_term = %s, billing_interval = %s, market = %s, valid_until = %s,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
@ -145,6 +165,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url,
supplier_product_url,
source,
variant_key, license_term, billing_interval, market, valid_until,
existing.get("id"),
)
)
@ -162,8 +183,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url,
supplier_product_url,
source,
variant_key, license_term, billing_interval, market, valid_until,
last_updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING
RETURNING *
"""
result = execute_query(
@ -179,9 +202,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url,
supplier_product_url,
source,
variant_key, license_term, billing_interval, market, valid_until,
)
)
return result[0] if result else {}
return result[0] if result else (execute_query_single(match_query, match_params) if match_query else {})
def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None:
@ -959,11 +983,13 @@ async def update_product(
):
"""Update product fields for core metadata and billing validation flags."""
try:
if any(key in payload for key in ('economic_product_number', 'economic_connection_id')):
raise HTTPException(409, 'Den permanente e-conomic-kobling kan ikke ændres ved almindelig produktredigering')
name = payload.get("name")
if name is not None:
name = name.strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
if not name or len(name) > 255:
raise HTTPException(status_code=400, detail="Navnet skal være 1255 tegn")
serial_number_required = payload.get("serial_number_required")
asset_required = payload.get("asset_required")
@ -980,8 +1006,8 @@ async def update_product(
if not existing:
raise HTTPException(status_code=404, detail="Product not found")
updates = ["updated_at = CURRENT_TIMESTAMP"]
values: List[Any] = []
updates = ["updated_at = CURRENT_TIMESTAMP", "updated_by = %s"]
values: List[Any] = [current_user.get("id")]
if name is not None:
updates.append("name = %s")

View File

@ -82,7 +82,7 @@
<div class="badge-soft" id="productStatus">-</div>
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
<div class="product-muted" id="productSku">-</div>
<div class="product-muted" id="productSupplierPrice">-</div>
<div class="product-muted" id="productSupplierPrice" data-cost-section>-</div>
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
</div>
</div>
@ -116,7 +116,7 @@
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
<div class="small mt-3" id="priceUpdateMessage"></div>
</div>
<div class="product-card mt-3">
<div class="product-card mt-3" data-cost-section>
<h5 class="mb-3">Opdater leverandør</h5>
<div class="mb-3">
<label class="form-label">Leverandør</label>
@ -191,7 +191,7 @@
</table>
</div>
</div>
<div class="product-card mt-3">
<div class="product-card mt-3" data-cost-section>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<h5 class="mb-0">Grosister og priser</h5>
<div class="d-flex align-items-center gap-2">
@ -258,6 +258,13 @@
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
</div>
<div class="col-12"><details><summary>Licensvariant og gyldighed (valgfrit)</summary><div class="row g-2 mt-1">
<div class="col-md-3"><label class="form-label">Variant-ID</label><input class="form-control" id="supplierVariant"></div>
<div class="col-md-2"><label class="form-label">Licensperiode</label><input class="form-control" id="supplierTerm" placeholder="P1Y / P1M"></div>
<div class="col-md-2"><label class="form-label">Betalingsinterval</label><input class="form-control" id="supplierInterval" placeholder="Monthly / Annual"></div>
<div class="col-md-2"><label class="form-label">Marked</label><input class="form-control" id="supplierMarket" placeholder="DK"></div>
<div class="col-md-3"><label class="form-label">Pris gyldig til</label><input class="form-control" id="supplierValidUntil" type="date"></div>
</div><p class="small text-muted mt-2">Forskellige varianter, perioder og markeder gemmes som separate leverandørtilbud.</p></details></div>
<div class="col-12">
<div class="small" id="supplierListMessage"></div>
</div>
@ -389,6 +396,10 @@ async function loadProductDetail() {
const res = await fetch(`/api/v1/products/${productId}`);
if (!res.ok) throw new Error('Kunne ikke hente produkt');
const product = await res.json();
const permissions = product._permissions || {};
document.querySelectorAll('[data-cost-section]').forEach(el => el.hidden = permissions.cost_view === false);
document.querySelectorAll('[onclick="submitPriceUpdate()"]').forEach(el => el.hidden = permissions.pricing === false);
if (permissions.cost_view !== false) await loadSupplierList();
document.getElementById('productName').textContent = product.name || 'Produkt';
document.getElementById('productMeta').textContent = [
product.manufacturer,
@ -482,11 +493,11 @@ async function loadSupplierList() {
return `
<tr>
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
<td>${escapeHtml(entry.supplier_sku || '-')}</td>
<td>${escapeHtml(entry.supplier_sku || '-')}<small class="d-block text-muted">${escapeHtml([entry.variant_key,entry.license_term,entry.billing_interval,entry.market].filter(Boolean).join(' · '))}</small></td>
<td>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td>
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}</td>
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}${entry.valid_until ? `<small class="d-block">Gyldig til ${escapeHtml(entry.valid_until)}</small>` : ''}</td>
<td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
</tr>
`;
@ -525,6 +536,11 @@ async function submitSupplierList() {
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null,
variant_key: document.getElementById('supplierVariant').value.trim(),
license_term: document.getElementById('supplierTerm').value.trim(),
billing_interval: document.getElementById('supplierInterval').value.trim(),
market: document.getElementById('supplierMarket').value.trim(),
valid_until: document.getElementById('supplierValidUntil').value || null,
source: 'manual'
};
@ -708,7 +724,6 @@ document.addEventListener('DOMContentLoaded', () => {
loadProductDetail();
loadPriceHistory();
loadSalesHistory();
loadSupplierList();
});
</script>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends 'shared/frontend/base.html' %}
{% block title %}Varer og e-conomic BMC Hub{% endblock %}
{% block content %}
<main id="economicCatalog" class="container-fluid py-4">
<div class="d-flex justify-content-between"><h2>Varer og e-conomic</h2><a href="/products" class="btn btn-outline-primary">Produktkatalog</a></div>
<p>Importér varer, knyt Hub-produkter til e-conomic og følg synkroniseringen.</p>
<div id="ecoFeedback" class="alert d-none" role="status"></div>
<div class="card mb-3"><div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center">
<select id="ecoConnection" class="form-select w-auto" aria-label="e-conomic-forbindelse"></select>
<button class="btn btn-outline-primary" data-action="connect">Tilknyt aftale</button>
<button class="btn btn-primary" data-action="preview">Kontrollér import</button>
<button class="btn btn-success" data-action="import">Importér alle varer</button>
<button class="btn btn-outline-secondary" data-action="config">Automatisk sync</button>
<span id="ecoMode"></span>
</div>
</div></div>
<nav class="nav nav-tabs mb-3"><button class="nav-link active" data-tab="products">Varer</button><button class="nav-link" data-tab="groups">Varegrupper</button><button class="nav-link" data-tab="rules">Prisregler</button><button class="nav-link" data-tab="changes">4-øjne-godkendelser</button><button class="nav-link" data-tab="jobs">Sync og fejl</button></nav>
<section id="ecoProducts">
<div class="d-flex gap-2 mb-3"><input id="ecoSearch" class="form-control" placeholder="Søg navn, SKU eller varenummer"><button class="btn btn-primary" data-action="search">Søg</button></div>
<label class="mb-3"><input id="ecoInactive" type="checkbox"> Vis også inaktive, spærrede og udfasede varer</label>
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Hub-ID</th><th>Varenummer</th><th>Navn</th><th>Hub-pris</th><th>Referencepris</th><th>Status</th><th>Handling</th></tr></thead><tbody id="ecoProductRows"></tbody></table></div>
<button class="btn btn-outline-secondary" data-action="previous">Forrige</button> <button class="btn btn-outline-secondary" data-action="next">Næste 100</button>
</section>
<section id="ecoGroups" hidden><table class="table table-sm"><thead><tr><th>Nummer</th><th>Navn</th><th>Hub-kategori</th><th>Nye varer</th><th>Handling</th></tr></thead><tbody id="ecoGroupRows"></tbody></table></section>
<section id="ecoRules" hidden><button class="btn btn-primary mb-3" data-action="rule">Ny prisregel</button><table class="table table-sm"><thead><tr><th>Navn</th><th>Type</th><th>Værdi</th><th>Prioritet</th><th>Handling</th></tr></thead><tbody id="ecoRuleRows"></tbody></table></section>
<section id="ecoChanges" hidden>
<div class="d-flex justify-content-between align-items-center mb-3"><div><h3>Ændringer i e-conomic</h3><p class="mb-0">Opretteren kan aldrig godkende sin egen ændring.</p></div><button class="btn btn-warning" data-action="change">Foreslå ændring</button></div>
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Tid</th><th>Objekt</th><th>Oprettet af</th><th>Før → efter</th><th>Begrundelse</th><th>Status</th><th>Handling</th></tr></thead><tbody id="ecoChangeRows"></tbody></table></div>
</section>
<section id="ecoJobs" hidden><p>Opdateres automatisk. Uafklarede oprettelser kræver kontrol før et nyt forsøg.</p><table class="table table-sm"><thead><tr><th>Tid</th><th>Type / vare</th><th>Status</th><th>Resultat / fejl</th><th>Handling</th></tr></thead><tbody id="ecoJobRows"></tbody></table>
<h3>Ordreeksport</h3><button class="btn btn-primary mb-3" data-action="document">Eksportér gemt ordre som kladde</button>
<p>Hub opretter kun en ordrekladde. Der bliver ikke bogført eller sendt til kunden.</p>
<table class="table table-sm"><thead><tr><th>Tid</th><th>Type</th><th>Status</th><th>Nummer / fejl</th><th>Handling</th></tr></thead><tbody id="ecoExportRows"></tbody></table>
</section>
<dialog id="ecoDialog" class="border rounded p-4" style="max-width:650px;width:95%">
<form id="ecoForm"><h3 id="ecoDialogTitle"></h3><div id="ecoFields"></div><p id="ecoFormError" class="text-danger" role="alert"></p><div class="d-flex gap-2 mt-3"><button type="submit" class="btn btn-primary">Gem</button><button type="button" class="btn btn-secondary" id="ecoCancel">Annullér</button></div></form>
</dialog>
</main>
<script src="/static/js/economic-catalog.js"></script>
{% endblock %}

View File

@ -324,6 +324,7 @@
<p>Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.</p>
</div>
<div class="products-actions">
<a class="btn btn-outline-primary" href="/settings/economic-products">Varer og e-conomic</a>
<button class="btn btn-primary" onclick="openCreateProductModal()">
<i class="bi bi-plus-circle"></i> Opret Produkt
</button>

View File

@ -287,9 +287,8 @@ 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}")
# Customer creation is allowed, updates are deliberately not.
logger.warning('e-conomic customer update blocked by the Hub write policy')
return False
try:
@ -379,7 +378,7 @@ class EconomicService:
Returns:
Created supplier data with supplierNumber or None if failed
"""
if not self._check_write_permission("create_supplier"):
logger.warning('e-conomic supplier creation blocked by the Hub write policy')
return None
try:
@ -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

View File

@ -977,20 +977,26 @@ class EmailService:
return {"success": False, "reason": "Email has no message id"}
headers = {"Authorization": f"Bearer {access_token}"}
params = {
"$filter": "internetMessageId eq '{}'".format(message_id.replace("'", "''")),
"$select": "id,subject,hasAttachments",
"$top": 2,
}
try:
async with ClientSession() as session:
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
clean_message_id = message_id.strip().strip('<>')
message_id_candidates = list(dict.fromkeys((message_id.strip(), f"<{clean_message_id}>")))
matches = []
for candidate in message_id_candidates:
params = {
"$filter": "internetMessageId eq '{}'".format(candidate.replace("'", "''")),
"$select": "id,subject,hasAttachments,internetMessageId",
"$top": 2,
}
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
detail = await response.text()
logger.warning("⚠️ Could not resolve Graph email %s: %s %s", email_id, response.status, detail)
return {"success": False, "reason": "Email could not be found in Microsoft Graph"}
logger.warning("⚠️ Could not resolve Graph email %s using %s: %s %s", email_id, candidate, response.status, detail)
continue
matches = (await response.json()).get("value", [])
if matches:
break
if not matches:
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}

View File

@ -1860,6 +1860,29 @@ class EmailWorkflowService:
elif not isinstance(attachments, list):
attachments = [attachments]
# Older/manual email imports may declare attachments without having saved
# their bytes. Recover the attachment before declaring the workflow failed.
if not attachments:
email_state = execute_query(
"SELECT has_attachments FROM email_messages WHERE id = %s",
(email_id,),
)
if email_state and email_state[0].get("has_attachments"):
try:
from app.services.email_service import EmailService
recovery = await EmailService().recover_graph_attachments(int(email_id))
if recovery.get("success"):
attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
OR LOWER(filename) LIKE '%%.pdf')""",
(email_id,),
) or []
except Exception as recovery_error:
logger.warning("Could not recover attachments for email %s: %s", email_id, recovery_error)
if not attachments:
logger.warning(f"⚠️ No PDF attachments found for email {email_id}")
return {
@ -1904,11 +1927,17 @@ class EmailWorkflowService:
# Check if file already exists
existing = execute_query(
"SELECT file_id FROM incoming_files WHERE checksum = %s",
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
(checksum,))
if existing:
logger.info(f"⚠️ File already exists: {attachment['filename']}")
uploaded_files.append({
'file_id': existing[0]['file_id'],
'filename': attachment['filename'],
'existing': True,
'status': existing[0].get('status'),
})
continue
# Create uploads directory if it doesn't exist
@ -1953,12 +1982,43 @@ class EmailWorkflowService:
continue
if uploaded_files:
processing_results = []
for uploaded in uploaded_files:
if uploaded.get('existing') and uploaded.get('status') in {'ai_extracted', 'processed'}:
processing_results.append({
'file_id': uploaded['file_id'],
'status': 'already_processed',
})
continue
try:
# Lazy import avoids coupling router import order to email startup.
from app.billing.backend.supplier_invoices import reprocess_uploaded_file
processing_results.append(await reprocess_uploaded_file(int(uploaded['file_id'])))
except Exception as processing_error:
logger.exception("Immediate invoice processing failed for file %s", uploaded['file_id'])
processing_results.append({
'file_id': uploaded['file_id'],
'status': 'failed',
'error': str(getattr(processing_error, 'detail', processing_error)),
})
failed_processing = [item for item in processing_results if item.get('status') == 'failed']
if failed_processing:
return {
'action': 'extract_invoice_data',
'success': False,
'files_uploaded': len(uploaded_files),
'file_ids': [f['file_id'] for f in uploaded_files],
'processing_results': processing_results,
'note': failed_processing[0].get('error') or 'Fakturaudtrækning fejlede',
}
return {
'action': 'extract_invoice_data',
'success': True,
'files_uploaded': len(uploaded_files),
'file_ids': [f['file_id'] for f in uploaded_files],
'note': f"{len(uploaded_files)} PDF(er) gemt i 'Mangler Behandling'"
'processing_results': processing_results,
'note': f"{len(uploaded_files)} PDF(er) gemt og behandlet"
}
else:
return {

View File

@ -460,6 +460,18 @@ REGLER FOR title:
# Parse JSON from response
extraction = self._parse_json_response(raw_response)
# Models do not always respect the numeric JSON schema and may
# return Danish/English amount strings. Normalize them before
# applying sign rules so comparisons with zero are type-safe.
for amount_key in ('total_amount', 'vat_amount'):
extraction[amount_key] = self._normalize_amount_value(extraction.get(amount_key))
if isinstance(extraction.get('lines'), list):
for line in extraction['lines']:
if not isinstance(line, dict):
continue
for amount_key in ('unit_price', 'line_total', 'vat_amount'):
line[amount_key] = self._normalize_amount_value(line.get(amount_key))
# CRITICAL: Fix amount signs based on document_type
# LLM sometimes returns negative amounts for invoices - fix this!
document_type = extraction.get('document_type', 'invoice')
@ -528,6 +540,39 @@ REGLER FOR title:
"confidence": 0.0
}
@staticmethod
def _normalize_amount_value(value):
"""Convert common Danish/English amount representations to numbers."""
if value is None or value == '':
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return value
if not isinstance(value, str):
return None
cleaned = re.sub(r'[^0-9,.-]', '', value.strip()).strip('.,')
if not cleaned or cleaned in {'-', '.', ',', '-.', '-,'}:
return None
if ',' in cleaned and '.' in cleaned:
if cleaned.rfind(',') > cleaned.rfind('.'):
cleaned = cleaned.replace('.', '').replace(',', '.')
else:
cleaned = cleaned.replace(',', '')
elif ',' in cleaned:
cleaned = cleaned.replace('.', '').replace(',', '.')
elif cleaned.count('.') > 1:
cleaned = cleaned.replace('.', '')
elif '.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3:
cleaned = cleaned.replace('.', '')
try:
return float(cleaned)
except ValueError:
return None
def _parse_json_response(self, response: str) -> Dict:
"""Parse JSON from LLM response with aggressive fallback strategies"""
logger.info(f"🔍 Response length: {len(response)}, preview: {response[:200]}")

View File

@ -162,6 +162,9 @@
<a class="nav-link" href="#integrations" data-tab="integrations">
<i class="bi bi-plugin me-2"></i>Integrationer
</a>
<a class="nav-link" href="/settings/economic-products">
<i class="bi bi-box-seam me-2"></i>Varer og e-conomic
</a>
<a class="nav-link" href="#telefoni" data-tab="telefoni">
<i class="bi bi-telephone me-2"></i>Telefoni
</a>
@ -263,6 +266,7 @@
<div class="card p-4">
<h5 class="mb-4 fw-bold">e-conomic</h5>
<a class="btn btn-primary mb-3" href="/settings/economic-products">Varer, priser og synkronisering</a>
<div id="economicSettings">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>

View File

@ -2006,7 +2006,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/notifications.js?v=1.0"></script>
<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/bug-report.js?v=1.6"></script>
{% include "shared/frontend/internal_message.html" %}
<script src="/static/js/message-ui.js?v=1"></script>
<script src="/static/js/bottom-bar.js?v=2.71"></script>

View File

@ -35,12 +35,12 @@
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="bugCaptureDisplayMediaBtn">
<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling
<i class="bi bi-display me-1"></i>Tag nyt screenshot af siden
</button>
</div>
<div id="bugReportStatus" class="small text-muted mt-2"></div>
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Hvis det fejler, brug skærmdeling-knappen eller indsæt med Cmd+V.</div>
<div class="small text-muted mt-2">Ved et nyt screenshot skjules denne formular kortvarigt, så fejlen på siden kommer med. Du kan også indsætte et billede med Cmd+V.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>

View File

@ -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
@ -196,6 +197,43 @@ class EconomicExportService:
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(
order_id=request.order_id,
@ -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(),

View File

@ -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.

View File

@ -4,7 +4,9 @@ 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.
## Arbejdsgang

View File

@ -0,0 +1,173 @@
# Sikkert fakturaudtræk med layoutanalyse og AI
## Status
Under udvikling. Første hybridlag med universel linjeudtrækning, AI-sammenligning,
matematisk validering og review-status er implementeret lokalt. Koordinatbaseret
PDF-evidens og visuel markering i originaldokumentet mangler fortsat.
## Formål
Erstat den nuværende tekst- og regex-tunge fakturabehandling med en revisionssikker hybridpipeline. Kritiske værdier må ikke accepteres alene på baggrund af et AI-svar.
## Integration i eksisterende flow
Den eksisterende behandling bevares som orkestrering, men ændres fra en fallback-kæde til flere uafhængige analyser:
```text
PDF
├─ original og checksum
├─ layoutudtræk med ordkoordinater
├─ deterministisk felt- og tabelparser
├─ kendt leverandørprofil/template
└─ AI-strukturering
sammenligning
matematisk validering
høj sikkerhed / manuel kontrol
```
## Nye komponenter
### `invoice_document_service`
- Åbner PDF'en én gang.
- Afgør om den har et brugbart tekstlag.
- Udtrækker ord, koordinater, sider og tabelkandidater.
- Renderer og OCR-behandler kun sider uden brugbart tekstlag.
- Gemmer et fælles dokumentformat til de øvrige analysatorer.
### `invoice_layout_parser`
- Finder labels og værdier ud fra position, ikke kun læserækkefølge.
- Genopbygger varetabeller ud fra kolonneoverskrifter og x-positioner.
- Samler fortsættelseslinjer under korrekt varelinje.
- Knytter model, EAN, KN8 og serienumre til den relevante position.
- Returnerer kildehenvisning og bounding box for hvert felt.
### `invoice_ai_analyzer`
- Modtager renset dokumenttekst og layoutparserens kandidater.
- Returnerer et fast JSON-schema.
- Bruges til semantisk strukturering og løsning af tvetydigheder.
- Må ikke overskrive sikre deterministiske værdier uden at skabe en konflikt.
### `invoice_validator`
- Sammenligner resultater fra layout, template og AI.
- Kontrollerer antal gange pris mod linjetotal.
- Kontrollerer subtotal, momsgrundlag, moms og total.
- Understøtter omvendt betalingspligt pr. varelinje.
- Kontrollerer sidetal, positionshuller, dubletter, valuta og fortegn.
- Afviser BMC's egne CVR-numre som leverandør-CVR.
### `invoice_review_service`
- Beregner samlet sikkerhed ud fra dokumenteret enighed og validering.
- Opretter konkrete review-punkter ved uenighed.
- Tillader manuel rettelse uden at slette det oprindelige resultat.
## Fælles resultatformat
Hvert kritisk felt skal indeholde:
- Normaliseret værdi
- Oprindelig tekst
- Side og bounding box
- Udtræksmetode
- AI-enighed
- Sikkerhed
- Eventuelle konflikter
Eksempel:
```json
{
"value": 17512.25,
"source_text": "Totalbeløb DKK 17.512,25",
"page": 2,
"bounding_box": [470, 315, 560, 332],
"methods": ["layout", "ai"],
"confidence": 0.99,
"validated": true
}
```
## Database
Bevar eksisterende faktura- og extraction-tabeller. Tilføj særskilt revisionsdata til:
- Dokumentudtræk og parser-version
- Felt-evidens
- Valideringsresultater
- Konflikter og advarsler
- Manuel korrektion
- Model- og prompt-version
Gem ikke store OCR-/layoutobjekter direkte i hovedtabellen, hvis de kan placeres i en separat analyse-/evidenstabel.
## Eksisterende templates
- Kendte invoice2data- og database-templates beholdes.
- De bliver en af flere kilder, ikke automatisk facit.
- Leverandørprofiler kan gemme tabelkolonner, labels, momsregler og kendte sideelementer.
- En profil aktiveres kun ved sikker identifikation af leverandør og dokumenttype.
## Brugerflade
Review-visningen skal vise PDF og udtrukne data side om side.
- Klik på et felt fremhæver kilden i PDF'en.
- Grøn betyder dokumenteret og valideret.
- Gul betyder lav sikkerhed eller mindre afvigelse.
- Rød betyder konflikt eller matematisk fejl.
- Brugeren ser den konkrete årsag til manuel kontrol.
- Manuelle rettelser gemmes med bruger og tidspunkt.
## Godkendelsesregler
Automatisk behandling må først ske, når alle følgende krav er opfyldt:
- Kendt og valideret leverandør
- Leverandør-CVR er ikke BMC's eget CVR
- Fakturanummer, dato, valuta og total har dokumenteret kilde
- Kritiske felter er bekræftet af mindst to metoder eller én metode plus matematisk bevis
- Alle relevante summer stemmer inden for afrundingstolerance
- Ingen manglende sider eller varepositioner
- Ingen ændrede betalingsoplysninger
- Ingen uløste konflikter
- Samlet sikkerhed over den fastsatte grænse
Indtil løsningen er evalueret på et repræsentativt datasæt, er manuel godkendelse standard.
## Fejlhåndtering
- AI-nedbrud må ikke stoppe deterministisk udtræk.
- OCR-fejl skal føre til manuel kontrol, ikke tomme værdier accepteret som succes.
- Ugyldig AI-JSON må aldrig gemmes som godkendt resultat.
- Parserfejl logges med dokument-, side-, parser- og modelversion.
- Genbehandling må ikke overskrive tidligere revisionsdata.
## Leveringsfaser
1. Fælles dokumentmodel med tekst, ordkoordinater og sider.
2. Koordinatbaseret parser for DCS-fakturaer og regressionstest med faktura 5509938.
3. Felt-evidens og matematisk validator.
4. AI-analyse med låst JSON-schema og konfliktsammenligning.
5. Review-UI med fremhævelse i PDF'en.
6. Pilot på historiske fakturaer uden automatisk bogføring.
7. Måling af præcision pr. leverandør og felttype.
8. Kontrolleret aktivering af automatisk behandling for godkendte leverandørprofiler.
## Acceptkriterier for DCS 5509938
- Alle otte varepositioner findes.
- Fakturanummer `5509938` og dato `2026-09-08` findes.
- DCS ApS og CVR `26686091` identificeres som leverandør.
- BMC CVR `29522790` identificeres som køber og ignoreres som leverandør.
- Varebeløb `17379.00`, momsgrundlag `533.00`, moms `133.25` og total `17512.25` findes.
- Omvendt betalingspligt knyttes til de relevante linjer.
- Kontrollen dokumenterer `17379.00 + 133.25 = 17512.25`.
- Hvert kritisk felt kan spores til side og placering i originaldokumentet.

View File

@ -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.

17
main.py
View File

@ -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
@ -226,6 +227,17 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)")
from app.modules.orders.backend.economic_sync import sync_due_drafts
backup_scheduler.scheduler.add_job(
func=sync_due_drafts,
trigger=CronTrigger(hour=4, minute=45),
id='economic_order_daily_status',
name='Daily e-conomic order and payment status',
max_instances=1,
replace_existing=True,
)
logger.info("✅ e-conomic ordre- og betalingsstatus planlagt dagligt kl. 04:45")
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
from app.jobs.eset_sync import run_eset_sync
@ -323,6 +335,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 +500,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"])

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,14 @@
-- Preserve administrator access after adding the e-conomic catalog permissions.
-- Migration 002 grants all permissions known at install time, so permissions
-- introduced later must also be assigned to the Administrators group.
BEGIN;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
CROSS JOIN permissions p
WHERE g.name = 'Administrators'
AND p.category = 'economic'
ON CONFLICT DO NOTHING;
COMMIT;

View File

@ -0,0 +1,32 @@
-- Persist the evidence needed to reconcile an order draft with e-conomic.
BEGIN;
ALTER TABLE ordre_drafts
ADD COLUMN IF NOT EXISTS economic_export_id VARCHAR(120),
ADD COLUMN IF NOT EXISTS economic_order_url TEXT,
ADD COLUMN IF NOT EXISTS exported_economic_customer_number VARCHAR(80),
ADD COLUMN IF NOT EXISTS exported_currency VARCHAR(3),
ADD COLUMN IF NOT EXISTS exported_layout_number INTEGER,
ADD COLUMN IF NOT EXISTS exported_net_amount NUMERIC(18,2),
ADD COLUMN IF NOT EXISTS export_request_json JSONB,
ADD COLUMN IF NOT EXISTS export_response_json JSONB,
ADD COLUMN IF NOT EXISTS exported_created_products JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS last_export_error TEXT,
ADD COLUMN IF NOT EXISTS exported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL;
ALTER TABLE ordre_drafts DROP CONSTRAINT IF EXISTS ordre_drafts_sync_status_check;
ALTER TABLE ordre_drafts ADD CONSTRAINT ordre_drafts_sync_status_check
CHECK (sync_status IN ('pending', 'uncertain', 'exported', 'failed', 'posted', 'paid'));
UPDATE ordre_drafts
SET sync_status = 'uncertain',
last_export_error = COALESCE(last_export_error, 'Tidligere eksport: eksport markeret gennemført uden gemt e-conomic-ordrenummer; afstem manuelt før ny eksport')
WHERE sync_status IN ('exported', 'posted', 'paid')
AND economic_order_number IS NULL;
UPDATE ordre_drafts
SET title = 'Abonnementer'
WHERE invoice_aggregate_key LIKE 'also-cloud-%'
AND title LIKE 'ALSO Cloud %';
COMMIT;

View File

@ -0,0 +1,48 @@
BEGIN;
ALTER TABLE ordre_drafts
ADD COLUMN IF NOT EXISTS economic_last_checked_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS economic_document_state VARCHAR(30),
ADD COLUMN IF NOT EXISTS economic_payment_status VARCHAR(30),
ADD COLUMN IF NOT EXISTS economic_status_response JSONB,
ADD COLUMN IF NOT EXISTS economic_status_error TEXT;
CREATE TABLE IF NOT EXISTS economic_order_update_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
draft_id INTEGER NOT NULL REFERENCES ordre_drafts(id) ON DELETE CASCADE,
economic_order_number VARCHAR(80) NOT NULL,
before_snapshot JSONB NOT NULL,
proposed_payload JSONB NOT NULL,
proposed_local_lines JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','applied','expired','failed')),
requested_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '15 minutes',
applied_at TIMESTAMPTZ,
response JSONB,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_economic_order_update_pending ON economic_order_update_requests(draft_id,status);
CREATE INDEX IF NOT EXISTS idx_ordre_economic_daily_check ON ordre_drafts(economic_last_checked_at) WHERE economic_order_number IS NOT NULL;
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 current_setting('bmc.economic_order_update', true) IS DISTINCT FROM OLD.id::text
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; use the approved e-conomic draft update flow';
END IF;
RETURN NEW;
END; $$ LANGUAGE plpgsql;
INSERT INTO permissions(code,description,category)
VALUES ('economic.orders.update','Forhåndsvis og godkend opdatering af en åben e-conomic-ordrekladde','economic')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions(group_id,permission_id)
SELECT g.id,p.id FROM groups g CROSS JOIN permissions p
WHERE g.name='Administrators' AND p.code='economic.orders.update'
ON CONFLICT DO NOTHING;
COMMIT;

View File

@ -0,0 +1,50 @@
BEGIN;
-- The old integration message was exposed as "Tekst 1" in e-conomic.
-- Keep user-entered notes intact and only remove the exact ALSO system text.
UPDATE ordre_drafts
SET notes = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE invoice_aggregate_key LIKE 'also-cloud-%'
AND notes = 'Genereret fra ALSO Cloud Billing approval';
-- Add the source service period to existing, still-pending ALSO order lines.
WITH rebuilt AS (
SELECT d.id,
jsonb_agg(
CASE
WHEN line.item->>'source_type' = 'also_cloud'
AND COALESCE(line.item->>'description', '') NOT LIKE '% · Periode: %'
AND source.charge_interval ~ '^\s*\d{2}[./-]\d{2}[./-]\d{4}\s*-\s*\d{2}[./-]\d{2}[./-]\d{4}\s*$'
THEN jsonb_set(
line.item,
'{description}',
to_jsonb(
COALESCE(line.item->>'description', 'Cloud abonnement')
|| ' · Periode: '
|| replace(source.charge_interval, '/', '.')
)
)
ELSE line.item
END
ORDER BY line.ordinality
) AS lines_json
FROM ordre_drafts d
CROSS JOIN LATERAL jsonb_array_elements(d.lines_json) WITH ORDINALITY AS line(item, ordinality)
LEFT JOIN also_import_lines source
ON source.id = CASE
WHEN line.item->>'source_id' ~ '^\d+$' THEN (line.item->>'source_id')::bigint
ELSE NULL
END
WHERE d.sync_status = 'pending'
AND d.invoice_aggregate_key LIKE 'also-cloud-%'
GROUP BY d.id
)
UPDATE ordre_drafts d
SET lines_json = rebuilt.lines_json,
updated_at = CURRENT_TIMESTAMP
FROM rebuilt
WHERE d.id = rebuilt.id
AND d.lines_json IS DISTINCT FROM rebuilt.lines_json;
COMMIT;

View File

@ -0,0 +1,26 @@
BEGIN;
-- Put the ALSO billing period on its own line for pending order drafts.
UPDATE ordre_drafts
SET lines_json = (
SELECT jsonb_agg(
CASE
WHEN item->>'source_type' = 'also_cloud'
AND COALESCE(item->>'description', '') LIKE '% · Periode: %'
THEN jsonb_set(
item,
'{description}',
to_jsonb(replace(item->>'description', ' · Periode: ', E'\nPeriode: '))
)
ELSE item
END
ORDER BY ordinality
)
FROM jsonb_array_elements(lines_json) WITH ORDINALITY AS source(item, ordinality)
),
updated_at = CURRENT_TIMESTAMP
WHERE sync_status = 'pending'
AND invoice_aggregate_key LIKE 'also-cloud-%'
AND lines_json::text LIKE '% · Periode: %';
COMMIT;

View File

@ -114,7 +114,7 @@
if (!el || !el.tagName) return false;
const tag = String(el.tagName).toUpperCase();
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
if (el.id === 'bugReportModal' || tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
return true;
}
@ -128,7 +128,7 @@
async function renderScreenshot(target, opts) {
const canvas = await window.html2canvas(target, opts);
return canvas.toDataURL('image/png');
return canvas.toDataURL('image/jpeg', 0.82);
}
async function ensureHtml2Canvas() {
@ -184,8 +184,21 @@
throw new Error('Canvas context unavailable');
}
const modalEl = document.getElementById('bugReportModal');
const backdrops = Array.from(document.querySelectorAll('.modal-backdrop'));
const modalVisibility = modalEl?.style.visibility || '';
const backdropVisibility = backdrops.map((item) => item.style.visibility || '');
try {
// Keep layout and entered form data intact, but expose the page underneath.
if (modalEl) modalEl.style.visibility = 'hidden';
backdrops.forEach((item) => { item.style.visibility = 'hidden'; });
await new Promise((resolve) => setTimeout(resolve, 300));
ctx.drawImage(video, 0, 0, width, height);
return canvas.toDataURL('image/png');
return canvas.toDataURL('image/jpeg', 0.82);
} finally {
if (modalEl) modalEl.style.visibility = modalVisibility;
backdrops.forEach((item, index) => { item.style.visibility = backdropVisibility[index]; });
}
} finally {
stream.getTracks().forEach((t) => t.stop());
}
@ -193,92 +206,22 @@
async function takeScreenshot() {
await ensureHtml2Canvas();
const doc = document.documentElement;
const body = document.body;
const fullWidth = Math.max(
doc ? doc.scrollWidth : 0,
doc ? doc.offsetWidth : 0,
doc ? doc.clientWidth : 0,
body ? body.scrollWidth : 0,
body ? body.offsetWidth : 0,
window.innerWidth || 0
);
const fullHeight = Math.max(
doc ? doc.scrollHeight : 0,
doc ? doc.offsetHeight : 0,
doc ? doc.clientHeight : 0,
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
window.innerHeight || 0
);
const common = {
return await renderScreenshot(document.body || document.documentElement, {
useCORS: true,
allowTaint: true,
allowTaint: false,
logging: false,
scale: 1,
backgroundColor: '#ffffff',
imageTimeout: 7000,
imageTimeout: 2500,
ignoreElements: shouldIgnoreInScreenshot,
removeContainer: true,
};
// Strategy 0: Viewport capture via foreignObject (works on many CSS-heavy pages)
try {
return await renderScreenshot(document.body || document.documentElement, {
...common,
foreignObjectRendering: true,
width: window.innerWidth,
height: window.innerHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scrollX: window.scrollX,
scrollY: window.scrollY,
scrollX: -window.scrollX,
scrollY: -window.scrollY,
});
} catch (e0) {
console.warn('Bug report screenshot strategy 0 failed', e0);
}
// Strategy 1: Full page (most useful when it works)
try {
return await renderScreenshot(document.documentElement, {
...common,
width: fullWidth,
height: fullHeight,
windowWidth: fullWidth,
windowHeight: fullHeight,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
});
} catch (e1) {
console.warn('Bug report screenshot strategy 1 failed', e1);
}
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
const contentRoot =
document.querySelector('.container-fluid.px-4.py-4') ||
document.querySelector('[data-bugreport-root]') ||
document.querySelector('#main-content') ||
document.querySelector('#content') ||
document.querySelector('main') ||
document.querySelector('.content-wrapper') ||
document.documentElement;
try {
return await renderScreenshot(contentRoot, {
...common,
width: Math.max(contentRoot.scrollWidth || 0, contentRoot.clientWidth || 0, window.innerWidth || 0),
height: Math.max(contentRoot.scrollHeight || 0, contentRoot.clientHeight || 0, window.innerHeight || 0),
scrollX: 0,
scrollY: 0,
});
} catch (e2) {
console.warn('Bug report screenshot strategy 2 failed', e2);
}
throw new Error('Automatic screenshot failed');
}
function setStatus(text, isError) {
@ -328,46 +271,48 @@
async function captureScreenshotBeforeModal() {
try {
screenshotDataUrl = await withTimeout(
takeScreenshotViaDisplayMedia(),
12000,
'Skærmvalg timed out'
);
return { ok: true, viaDisplayMedia: true };
} catch (displayErr) {
console.warn('Bug report display-media first attempt failed', displayErr);
}
const attemptAutoCapture = async () => {
const promise = pendingScreenshotPromise || takeScreenshot();
pendingScreenshotPromise = promise;
return await promise;
};
try {
screenshotDataUrl = await attemptAutoCapture();
return { ok: true };
} catch (firstError) {
console.warn('Bug report screenshot first attempt failed', firstError);
try {
pendingScreenshotPromise = withTimeout(
takeScreenshot(),
8000,
'Automatic screenshot timed out'
pendingScreenshotPromise = pendingScreenshotPromise || withTimeout(
takeScreenshot(), 7000, 'Automatisk screenshot tog for lang tid'
);
screenshotDataUrl = await pendingScreenshotPromise;
return { ok: true, recovered: true };
} catch (secondError) {
console.warn('Bug report screenshot retry failed', secondError);
return { ok: true };
} catch (error) {
console.warn('Bug report screenshot failed', error);
screenshotDataUrl = null;
return { ok: false, error: secondError || firstError };
}
return { ok: false, error };
} finally {
pendingScreenshotPromise = null;
}
}
function formatError(value, fallback) {
if (!value) return fallback;
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
return value.map((item) => formatError(item, '')).filter(Boolean).join(' · ') || fallback;
}
if (typeof value === 'object') {
if (value.msg) return String(value.msg);
if (value.message) return String(value.message);
if (value.detail) return formatError(value.detail, fallback);
try { return JSON.stringify(value); } catch (_) { return fallback; }
}
return String(value);
}
function startFastBugReport() {
openBugReportModal('Formularen er klar. Screenshot tages i baggrunden…', false);
captureScreenshotBeforeModal().then((capture) => {
setPreview(screenshotDataUrl);
if (capture.ok) {
setStatus('Screenshot klar. Udfyld felterne og send.');
} else {
const reason = formatError(capture.error, 'ukendt fejl');
setStatus(`Screenshot kunne ikke tages (${reason}). Prøv "Tag nyt screenshot af siden" eller Cmd+V.`, true);
}
});
}
async function openBugReportModal(statusText, statusIsError) {
if (!bugModal) {
const modalEl = document.getElementById('bugReportModal');
@ -379,7 +324,7 @@
setStatus(
statusText || (screenshotDataUrl
? 'Screenshot klar. Udfyld felterne og send.'
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.'),
: 'Kunne ikke tage screenshot automatisk. Klik "Tag nyt screenshot af siden" eller indsæt med Cmd+V.'),
Boolean(statusIsError)
);
@ -411,7 +356,7 @@
const dataUrl = await takeScreenshotViaDisplayMedia();
screenshotDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('Screenshot taget via skærmdeling.');
setStatus('Nyt screenshot af siden er taget uden bugformularen.');
} catch (e) {
console.warn('Bug report display media capture failed', e);
setStatus('Skærmdelings-screenshot mislykkedes. Prøv igen eller indsæt med Cmd+V.', true);
@ -419,7 +364,7 @@
isCapturingDisplayMedia = false;
if (btn) {
btn.disabled = false;
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag screenshot via skærmdeling';
btn.innerHTML = originalHtml || '<i class="bi bi-display me-1"></i>Tag nyt screenshot af siden';
}
}
}
@ -488,7 +433,7 @@
if (!res || !res.ok) {
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
throw new Error(detail);
throw new Error(formatError(detail, 'Kunne ikke sende fejlrapport'));
}
setStatus('Fejl rapporteret.');
@ -512,22 +457,13 @@
const displayMediaBtn = document.getElementById('bugCaptureDisplayMediaBtn');
const modalEl = document.getElementById('bugReportModal');
// Load the renderer before it is needed so opening a report does not wait on the CDN.
ensureHtml2Canvas().catch((error) => console.warn('Screenshot library preload failed', error));
if (btn) {
btn.addEventListener('click', async (e) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const capture = await captureScreenshotBeforeModal();
if (capture.ok) {
const msg = capture.viaDisplayMedia
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
: 'Screenshot taget ved klik. Udfyld felterne og send.';
openBugReportModal(msg, false);
} else {
const reason = String(capture?.error?.message || '').trim();
const errorMsg = reason
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
openBugReportModal(errorMsg, true);
}
startFastBugReport();
});
}
@ -567,20 +503,7 @@
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
captureScreenshotBeforeModal().then((capture) => {
if (capture.ok) {
const msg = capture.viaDisplayMedia
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
: 'Screenshot taget ved klik. Udfyld felterne og send.';
openBugReportModal(msg, false);
} else {
const reason = String(capture?.error?.message || '').trim();
const errorMsg = reason
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
openBugReportModal(errorMsg, true);
}
});
startFastBugReport();
}
});
});

View File

@ -0,0 +1,105 @@
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 `<label class="form-label d-block mt-2">${esc(label)}<input class="form-control" name="${name}" type="${type}" ${type==='number'?'step="any"':''} value="${esc(value)}"></label>`; }
function select(name,label,items) { return `<label class="form-label d-block mt-2">${esc(label)}<select name="${name}" class="form-select"><option value="">Vælg…</option>${items.map(x=>`<option value="${x.number}">${esc(x.number+' · '+x.name)}</option>`).join('')}</select></label>`; }
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=>`<option value="${c.id}">${esc(c.name)} · ${esc(c.agreement_number)}</option>`).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=>`<tr><td>${esc(j.created_at)}</td><td>${esc(j.kind)} ${esc(j.product_id||'')}</td><td>${esc(labels[j.status]||j.status)}</td><td>${esc(j.error||JSON.stringify(j.result))}<small class="d-block text-muted">${esc(j.id)}</small></td><td>${j.status==='failed'&&j.kind!=='create'?`<button class="btn btn-sm btn-outline-primary" data-retry="${esc(j.id)}">Prøv igen</button>`:''}</td></tr>`).join('');
for (const job of state.jobs.filter(j=>j.status==='uncertain'&&j.kind==='create')) {
const row=document.createElement('tr');row.innerHTML=`<td colspan="5">Uafklaret vare ${esc(job.product_id)} <button class="btn btn-warning btn-sm" data-reconcile-product="${esc(job.id)}">Afstem vare</button></td>`;$('ecoJobRows').append(row);
}
$('ecoExportRows').innerHTML=(state.exports||[]).map(x=>`<tr><td>${esc(x.created_at)}</td><td>${esc(x.kind)}</td><td>${esc(x.status)}</td><td>${esc(x.economic_number||x.error||'—')}<small class="d-block">${esc(x.id)}</small></td><td>${x.status==='uncertain'?`<button class="btn btn-warning btn-sm" data-reconcile-export="${esc(x.id)}">Afstem</button>`:x.status==='rejected'?`<button class="btn btn-outline-primary btn-sm" data-retry-export="${esc(x.id)}">Frigiv til rettet forsøg</button>`:''}</td></tr>`).join('');
$('ecoChangeRows').innerHTML=(state.change_requests||[]).map(x=>{
const fields=Object.keys(x.requested_changes||{}).map(key=>`${esc(key)}: ${esc(JSON.stringify((x.before_snapshot||{})[key]))} → ${esc(JSON.stringify((x.proposed_snapshot||{})[key]))}`).join('<br>');
const actions=x.status==='pending'&&state.permissions['economic.changes.approve']?`<button class="btn btn-success btn-sm" data-approve-change="${esc(x.id)}">Godkend</button> <button class="btn btn-outline-danger btn-sm" data-reject-change="${esc(x.id)}">Afvis</button>`:'';
return `<tr><td>${esc(x.requested_at)}</td><td>${esc(x.target_type==='product'?'Vare':'Kunde')} ${esc(x.target_number)}</td><td>${esc(x.requester_name||x.requested_by_user_id)}</td><td>${fields}</td><td>${esc(x.reason)}</td><td>${esc(labels[x.status]||x.status)}${x.error?`<small class="d-block text-danger">${esc(x.error)}</small>`:''}</td><td>${actions}</td></tr>`;
}).join('');
document.querySelectorAll('[data-action="connect"],[data-action="config"],[data-action="preview"],[data-action="import"],[data-retry],[data-reconcile-product],[data-reconcile-export]').forEach(b=>b.hidden=!state.permissions['economic.catalog.manage']);
document.querySelectorAll('[data-tab="rules"],[data-action="rule"]').forEach(b=>b.hidden=!state.permissions['economic.pricing.manage']);
document.querySelectorAll('[data-action="document"],[data-retry-export]').forEach(b=>b.hidden=!state.permissions['economic.documents.export']);
document.querySelectorAll('[data-action="change"]').forEach(b=>b.hidden=!state.permissions['economic.changes.request']);
}
async function loadProducts() {
products=await api('/economic/catalog/products?q='+encodeURIComponent($('ecoSearch').value)+'&offset='+offset+'&include_inactive='+$('ecoInactive').checked);
$('ecoProductRows').innerHTML=products.map(p=>`<tr><td>${p.id}</td><td>${esc(p.economic_product_number||'—')}</td><td><a href="/products/${p.id}">${esc(p.name)}</a></td><td>${esc(p.sales_price??'—')} ${esc(p.sales_currency)}</td><td>${esc(p.economic_sales_price_reference??'—')} ${esc(p.economic_currency)}</td><td>${esc(labels[p.economic_sync_status]||p.economic_sync_status)}${p.is_active_in_economic===false?' · Spærret':''}</td><td><button class="btn btn-sm btn-outline-primary" data-product="${p.id}">Åbn</button></td></tr>`).join('');
[...$('ecoProductRows').rows].forEach((row,index)=>{
const p=products[index], cell=row.lastElementChild;
row.querySelector('[data-product]').hidden=!state.permissions['economic.catalog.manage'];
if(state.permissions['economic.pricing.manage'])cell.insertAdjacentHTML('beforeend',` <button class="btn btn-sm btn-outline-primary" data-price="${p.id}">Pris</button>`);
if(state.permissions['economic.catalog.manage'])cell.insertAdjacentHTML('beforeend',` <button class="btn btn-sm btn-outline-secondary" data-state="${p.id}">Status</button>`);
if(p.economic_product_number&&state.permissions['economic.catalog.rebind'])cell.insertAdjacentHTML('beforeend',` <button class="btn btn-sm btn-outline-danger" data-rebind="${p.id}">Omkobl</button>`);
});
}
async function loadRefs() {
refs=connection()?await api(`/economic/catalog/connections/${connection()}/references`):[];
$('ecoGroupRows').innerHTML=refs.filter(r=>r.kind==='product-groups').map(r=>`<tr><td>${r.number}</td><td>${esc(r.name)}</td><td>${esc(r.category_id||'—')}</td><td>${r.phasing_out?'Udfases':r.allow_new?'Tilladt':'Blokeret'}</td><td><button class="btn btn-sm btn-outline-primary" data-group="${r.number}">Redigér</button></td></tr>`).join('');
}
async function loadRules() { const rules=await api('/economic/catalog/price-rules'); $('ecoRuleRows').innerHTML=rules.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td>${esc(r.value)} ${esc(r.currency)}</td><td>${r.priority}</td><td>${r.active?`<button class="btn btn-sm btn-outline-danger" data-rule="${r.id}">Deaktivér</button>`:'Inaktiv'}</td></tr>`).join(''); }
async function action(name) {
if(name==='search'||name==='previous'||name==='next') {offset=name==='next'?offset+100:name==='previous'?Math.max(0,offset-100):0;return loadProducts();}
if(name==='connect') {const identity=await api('/economic/catalog/identity');return modal('Bekræft e-conomic-aftalen',field('agreement_number','Aftalenummer','text',identity.agreement_number)+field('name','Navn','text',identity.name)+field('currency','Aftalens basisvaluta','text',identity.currency), d=>api('/economic/catalog/connections','POST',d));}
if(!connection()) throw Error('Tilknyt først jeres e-conomic-aftale');
if(name==='document') {
return modal('Kontrollér ordreeksport',field('draft_id','Gemt ordrekladde-ID','number')+'<p>Hub opretter kun en ordrekladde. Fakturakladder, bogføring og afsendelse ændres aldrig fra Hub.</p>',async d=>{
const draft=await api(`/ordre/drafts/${Number(d.draft_id)}`);
const body={document_key:`order-draft:${draft.id}`,customer_id:draft.customer_id,lines:draft.lines_json,layout_number:draft.layout_number,currency:'DKK',notes:draft.notes};
const preview=await api('/economic/documents/order/preview','POST',body);
const total=preview.lines.reduce((sum,l)=>sum+Number(l.net_total),0).toFixed(2);
if(!window.confirm(`${preview.lines.length} linjer · ${total} DKK ekskl. moms.\nOpret ordrekladde i e-conomic?`))throw Error('Eksport annulleret; intet sendt.');
await api('/economic/documents/order/export','POST',body);
});
}
if(name==='preview'||name==='import') { const j=await api(`/economic/catalog/connections/${connection()}/sync?preview=${name==='preview'}`,'POST');feedback('Job oprettet: '+j.id);return refresh(); }
if(name==='config') {
const c=state.connections.find(x=>x.id===connection());
await loadRefs();
modal('Automatisk synkronisering',`<label><input type="checkbox" name="enabled" ${c.enabled?'checked':''}> Integration aktiv (streng varekontrol ved eksport)</label><br><label><input type="checkbox" name="auto_sync" ${c.auto_sync?'checked':''}> Automatisk import</label>`+field('sync_interval_minutes','Interval i minutter','number',c.sync_interval_minutes)+field('number_prefix','Præfiks for nye varer','text',c.number_prefix)+select('time_unit_number','Enhed for tidsregistrering (vælg timer)',refs.filter(r=>r.kind==='units')),d=>api(`/economic/catalog/connections/${connection()}`,'PATCH',{...d,enabled:d.enabled==='on',auto_sync:d.auto_sync==='on',sync_interval_minutes:Number(d.sync_interval_minutes),time_unit_number:Number(d.time_unit_number)||null}));
$('ecoFields').querySelector('[name="time_unit_number"]').value=c.time_unit_number||'';
return;
}
if(name==='rule') return modal('Ny prisregel',field('name','Navn')+field('product_id','Produkt-ID (valgfrit)','number')+field('customer_id','Kunde-ID (valgfrit)','number')+field('category_id','Kategori-ID (valgfrit)','number')+'<label class="d-block">Type<select class="form-select" name="kind"><option value="fixed">Fast pris</option><option value="discount">Rabat i %</option><option value="cost_markup">Tillæg på kost i %</option></select></label>'+field('value','Værdi','number')+field('currency','Valuta','text','DKK')+field('priority','Prioritet','number','0'),d=>api('/economic/catalog/price-rules','POST',{...d,product_id:Number(d.product_id)||null,customer_id:Number(d.customer_id)||null,category_id:Number(d.category_id)||null,value:Number(d.value),priority:Number(d.priority)}));
if(name==='change') return modal('Foreslå ændring i e-conomic','<label class="form-label d-block">Type<select class="form-select" name="target_type"><option value="customer">Kunde</option><option value="product">Vare</option></select></label>'+field('target_number','Kunde-/varenummer')+'<label class="form-label d-block mt-2">Felt<select class="form-select" name="field"><option value="name">Navn</option><option value="address">Adresse (kunde)</option><option value="zip">Postnummer (kunde)</option><option value="city">By (kunde)</option><option value="email">E-mail (kunde)</option><option value="telephoneAndFaxNumber">Telefon (kunde)</option><option value="salesPrice">Standardpris (vare)</option><option value="barred">Spærret true/false (vare)</option></select></label>'+field('value','Ny værdi')+field('reason','Begrundelse (mindst 10 tegn)')+'<p class="alert alert-info mt-3">Forslaget ændrer intet nu. En anden godkender skal kontrollere før/efter og godkende.</p>',d=>{let value=d.value;if(d.field==='salesPrice')value=Number(value);if(d.field==='barred')value=String(value).toLowerCase()==='true';return api('/economic/change-requests','POST',{connection_id:connection(),target_type:d.target_type,target_number:d.target_number,changes:{[d.field]:value},reason:d.reason});});
}
$('economicCatalog').addEventListener('click',async event=>{try {
const b=event.target.closest('button');if(!b)return;
if(b.dataset.tab) {for(const t of ['products','groups','rules','changes','jobs']) $('eco'+t[0].toUpperCase()+t.slice(1)).hidden=t!==b.dataset.tab;document.querySelectorAll('[data-tab]').forEach(n=>n.classList.toggle('active',n===b));if(b.dataset.tab==='groups')await loadRefs();if(b.dataset.tab==='rules')await loadRules();}
if(b.dataset.action)await action(b.dataset.action);
if(b.dataset.retry){await api(`/economic/catalog/jobs/${b.dataset.retry}/retry`,'POST');await refresh();}
if(b.dataset.reconcileProduct){await api(`/economic/catalog/jobs/${b.dataset.reconcileProduct}/reconcile`,'POST');await refresh();await loadProducts();}
if(b.dataset.reconcileExport){await api(`/economic/documents/exports/${b.dataset.reconcileExport}/reconcile`,'POST');await refresh();}
if(b.dataset.retryExport){await api(`/economic/documents/exports/${b.dataset.retryExport}/retry`,'POST');await refresh();}
if(b.dataset.rebind){const p=products.find(x=>x.id===Number(b.dataset.rebind));modal('Administrativ omkobling: '+p.name,`<p class="text-danger">Dette er en særskilt ændring af varens permanente kobling. Historiske dokumenter ændres ikke. Nuværende nummer: ${esc(p.economic_product_number)}</p>`+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')+'<p>Dette ændrer ikke referenceprisen i e-conomic eller eksisterende aftalepriser.</p>',d=>api(`/products/${p.id}/economic-pricing`,'PATCH',d));}
if(b.dataset.state){const p=products.find(x=>x.id===Number(b.dataset.state));modal('Intern status: '+p.name,'<label>Status<select class="form-select" name="lifecycle_status">'+[['draft','Kladde'],['active','Aktiv'],['phasing_out','Udfases'],['inactive','Inaktiv']].map(([value,label])=>`<option value="${value}" ${p.lifecycle_status===value?'selected':''}>${label}</option>`).join('')+'</select></label>'+field('category_id','Kategori-ID','number',p.category_id||'')+field('subcategory_id','Underkategori-ID','number',p.subcategory_id||''),d=>api(`/products/${p.id}/economic-state`,'PATCH',{...d,category_id:Number(d.category_id)||null,subcategory_id:Number(d.subcategory_id)||null}));}
if(b.dataset.rule){await api(`/economic/catalog/price-rules/${b.dataset.rule}`,'DELETE');await loadRules();}
if(b.dataset.group){const r=refs.find(x=>x.kind==='product-groups'&&x.number===Number(b.dataset.group));modal('Varegruppe '+r.name,field('category_id','Standardkategori-ID','number',r.category_id||'')+`<label><input type="checkbox" name="allow_new" ${r.allow_new?'checked':''}> Tillad nye varer</label><br><label><input type="checkbox" name="phasing_out" ${r.phasing_out?'checked':''}> Udfases</label>`,d=>api(`/economic/catalog/connections/${connection()}/groups/${r.number}`,'PATCH',{category_id:Number(d.category_id)||null,allow_new:d.allow_new==='on',phasing_out:d.phasing_out==='on'}));}
if(b.dataset.product){const p=products.find(x=>x.id===Number(b.dataset.product));await loadRefs();if(p.economic_product_number)modal(p.name,field('name','Produktnavn i Hub','text',p.name)+`<p>Låst e-conomic-varenummer: ${esc(p.economic_product_number)}</p><p class="text-muted">Navnet gemmes kun i Hub. Det ændrer aldrig den eksisterende e-conomic-vare.</p>`+(p.economic_sync_status==='conflict'?'<button type="button" class="btn btn-outline-primary" data-accept-economic="'+p.id+'">Brug e-conomic-navn i Hub</button>':''),d=>api(`/products/${p.id}`,'PATCH',{name:d.name}));else modal(p.name,'<label>Handling<select name="operation" class="form-select"><option value="link">Knyt eksisterende varenummer</option><option value="create">Opret ny vare i e-conomic</option></select></label>'+field('number','Eksisterende varenummer (ved tilknytning)')+select('group','Varegruppe (ved oprettelse)',refs.filter(r=>r.kind==='product-groups'&&r.allow_new&&!r.phasing_out))+select('unit','Enhed (ved oprettelse)',refs.filter(r=>r.kind==='units')),d=>api(`/products/${p.id}/economic-${d.operation}`,'POST',d.operation==='link'?{connection_id:connection(),number:d.number}:{connection_id:connection(),group_number:Number(d.group),unit_number:Number(d.unit)||null}));}
if(b.dataset.acceptEconomic){await api(`/products/${b.dataset.acceptEconomic}/economic-resolve`,'POST',{choice:'economic'});await loadProducts();}
if(b.dataset.approveChange){if(window.confirm('Godkend og udfør denne ændring i e-conomic?')){await api(`/economic/change-requests/${b.dataset.approveChange}/approve`,'POST',{note:'Godkendt i Hub'});await refresh();}}
if(b.dataset.rejectChange){const note=window.prompt('Hvorfor afvises ændringen?','Afvist efter kontrol');if(note!==null){await api(`/economic/change-requests/${b.dataset.rejectChange}/reject`,'POST',{note});await refresh();}}
}catch(e){feedback(e.message,true);}});
$('ecoForm').addEventListener('submit',async e=>{e.preventDefault();const button=e.submitter||e.target.querySelector('[type="submit"]');button.disabled=true;try{await saveForm(Object.fromEntries(new FormData(e.target)));$('ecoDialog').close();feedback('Gemt. Eventuel synkronisering kan følges under Sync og fejl.');await refresh();await loadProducts();await loadRefs();if(!$('ecoRules').hidden)await loadRules();}catch(error){$('ecoFormError').textContent=error.message;}finally{button.disabled=false;}});
$('ecoCancel').onclick=()=>$('ecoDialog').close();
$('ecoConnection').onchange=()=>loadRefs().catch(e=>feedback(e.message,true));
$('ecoInactive').onchange=()=>loadProducts().catch(e=>feedback(e.message,true));
refresh().then(loadProducts).catch(e=>feedback(e.message,true));
setInterval(()=>{if(!document.hidden&&!$('ecoDialog').open)refresh().catch(e=>feedback(e.message,true));},15000);
})();

View File

@ -0,0 +1,65 @@
/* Isolated UI test: every request is intercepted; no Hub/e-conomic data is changed.
* BMC_PLAYWRIGHT_MODULE=/path/to/playwright-core node tests/browser/economic_catalog.cjs
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const {chromium} = require(process.env.BMC_PLAYWRIGHT_MODULE || 'playwright-core');
(async () => {
const browser = await chromium.launch({headless:true, executablePath:process.env.BMC_CHROME_EXECUTABLE || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
const page = await browser.newPage({viewport:{width:1440,height:1000}});
const errors = [], writes = [];
page.on('pageerror', e=>errors.push(e.message));
const html = '<!doctype html><meta charset="utf-8">' + fs.readFileSync('app/products/frontend/economic.html','utf8')
.replace(/{%[^%]*%}/g,'');
const script = fs.readFileSync('static/js/economic-catalog.js','utf8');
const permissions = Object.fromEntries(['economic.catalog.manage','economic.catalog.rebind','economic.pricing.manage','economic.documents.export','products.update'].map(k=>[k,true]));
let products = [{id:1,name:'Microsoft 365',economic_product_number:null,economic_sync_status:'unlinked',sales_price:100,sales_currency:'DKK',lifecycle_status:'active'}];
const connection = {id:1,name:'Testaftale',agreement_number:'123',currency:'DKK',enabled:false,auto_sync:false,number_prefix:'TEST-',sync_interval_minutes:15};
await page.route('**/*', async route=>{
const url = new URL(route.request().url()), path=url.pathname;
if (path === '/') return route.fulfill({contentType:'text/html',body:html});
if (path === '/static/js/economic-catalog.js') return route.fulfill({contentType:'application/javascript',body:script});
if (route.request().method() !== 'GET') writes.push({path,body:route.request().postDataJSON()});
let data = {};
if(path.endsWith('/status'))data={connections:[connection],permissions,read_only:true,dry_run:true,jobs:[],exports:[]};
else if(path.endsWith('/references'))data=[{kind:'product-groups',number:1,name:'Software',allow_new:true},{kind:'units',number:1,name:'stk'}];
else if(path === '/api/v1/economic/catalog/products')data=products;
else if(path.endsWith('/economic-link')) { products=[{...products[0],economic_product_number:'00123',economic_sync_status:'synced'}];data={linked:true}; }
else if(path.endsWith('/price-rules') && route.request().method()==='GET')data=[];
return route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
});
try {
await page.goto('http://catalog.test/');
await page.getByRole('button',{name:'Åbn',exact:true}).click();
await page.locator('input[name=number]').fill('00123');
// Linking must work without selecting group/unit (only needed for creation).
await page.getByRole('button',{name:'Gem',exact:true}).click();
await page.locator('#ecoDialog').waitFor({state:'hidden'});
assert(writes.some(w=>w.path.endsWith('/economic-link')&&w.body.number==='00123'));
await page.getByRole('button',{name:'Pris',exact:true}).click();
await page.locator('input[name=sales_price]').fill('123.45');
await page.getByRole('button',{name:'Gem',exact:true}).click();
await page.locator('#ecoDialog').waitFor({state:'hidden'});
assert(writes.some(w=>w.path.endsWith('/economic-pricing')&&w.body.sales_price==='123.45'));
await page.getByRole('button',{name:'Status',exact:true}).click();
await page.locator('select[name=lifecycle_status]').selectOption('phasing_out');
await page.getByRole('button',{name:'Gem',exact:true}).click();
await page.locator('#ecoDialog').waitFor({state:'hidden'});
assert(writes.some(w=>w.path.endsWith('/economic-state')&&w.body.lifecycle_status==='phasing_out'));
await page.getByRole('button',{name:'Prisregler',exact:true}).click();
await page.getByRole('button',{name:'Ny prisregel',exact:true}).click();
await page.locator('input[name=name]').fill('Test rabat');
await page.locator('input[name=value]').fill('12.5');
await page.getByRole('button',{name:'Gem',exact:true}).click();
await page.locator('#ecoDialog').waitFor({state:'hidden'});
assert(writes.some(w=>w.path.endsWith('/price-rules')&&w.body.value===12.5));
await page.getByRole('button',{name:'Sync og fejl',exact:true}).click();
assert(await page.getByRole('button',{name:'Eksportér gemt ordre som kladde'}).isVisible());
assert.deepEqual(errors, []);
console.log('PASS: link without group/unit, decimal price, lifecycle, price rule, exports tab; zero JS errors');
} catch (error) {
console.error('UI errors:', errors, 'Feedback:', await page.locator('#ecoFeedback').textContent().catch(()=>''));
throw error;
} finally { await browser.close(); }
})().catch(error=>{console.error(error);process.exitCode=1;});

View File

@ -0,0 +1,395 @@
"""No e-conomic network traffic. DB tests are opt-in and roll back their rows."""
import asyncio
import os
from contextlib import contextmanager
from datetime import date, timedelta
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app.products.backend import economic_catalog as c
from app.products.backend import economic_documents as d
def product(**values):
return dict(id=12, name='Testvare', sales_price='100.00', sales_currency='DKK',
cost_price='40', cost_currency='DKK', economic_product_number='001-ABC',
economic_connection_id=1, economic_unit_number=1, status='active',
lifecycle_status='active', is_active_in_economic=True, **values)
def rule(**values):
base = dict(id=1, name='Rabat', kind='discount', value=10, currency='DKK', priority=0)
base.update(values)
return base
@pytest.mark.parametrize('value', ['00123', 'MS-365', 'a'*25])
def test_product_number_preserved(value):
assert c.product_number(value) == value
@pytest.mark.parametrize('value', [123, '', ' ', 'a'*26, None])
def test_product_number_rejected(value):
with pytest.raises(HTTPException):
c.product_number(value)
@pytest.mark.parametrize('value', ['NaN', 'Infinity', '-Infinity', None, 'abc'])
def test_decimal_rejects_non_finite(value):
with pytest.raises(HTTPException):
c.decimal(value)
def test_price_priority_and_zero():
rules = [rule(id=1, kind='fixed', value=80, category_id=4, priority=100),
rule(id=2, kind='fixed', value=70, product_id=12),
rule(id=3, kind='fixed', value=60, customer_id=2),
rule(id=4, kind='fixed', value=0, customer_id=2, product_id=12)]
assert c.calculate_price(product(category_id=4), rules, 2)['unit_price'] == '0.00'
assert c.calculate_price(product(), rules, 2, manual='12.345')['unit_price'] == '12.35'
def test_rule_conflict_not_resolved_by_row_order():
with pytest.raises(HTTPException, match='409'):
c.calculate_price(product(), [rule(), rule(id=2)])
def test_expired_rules_ignored():
assert c.calculate_price(product(), [rule(valid_to=date.today()-timedelta(days=1))])['unit_price'] == '100.00'
def test_cost_markup_is_not_margin():
assert c.calculate_price(product(), [rule(kind='cost_markup', value=25)])['unit_price'] == '50.00'
def test_unknown_cost_currency_blocks_markup():
p = product()
p['cost_currency'] = None
with pytest.raises(HTTPException):
c.calculate_price(p, [rule(kind='cost_markup')])
def test_reference_price_fallback_keeps_currency():
p = product()
p.update(sales_price=None, economic_sales_price_reference='22', economic_currency='EUR')
assert c.calculate_price(p, [], currency='EUR')['unit_price'] == '22.00'
with pytest.raises(HTTPException):
c.calculate_price(p, [], currency='DKK')
def test_snapshot_preserves_saved_price_and_zero():
snap = d.line_snapshot({'quantity': 2, 'unit_price': 0}, product(), 1, 'DKK')
assert snap['unit_price'] == '0.00'
assert snap['economic_product_number'] == '001-ABC'
assert snap['net_total'] == '0.00'
def test_order_external_reference_uses_hub_order_id_and_keeps_legacy_fallback():
assert d.external_reference('export-uuid', 16) == '16'
assert d.external_reference('export-uuid') == 'BMC-HUB:export-uuid'
def test_also_charge_period_is_added_only_for_an_explicit_date_range():
from app.modules.also.backend.service import AlsoService, _format_charge_period
assert _format_charge_period('01/09/2026 - 30/09/2026') == '01.09.2026 - 30.09.2026'
assert _format_charge_period('monthly') is None
assert _format_charge_period(None) is None
normalized = AlsoService()._normalize_import_row(
{
'Company': 'Testkunde',
'Product Name': 'Microsoft 365',
'Total Price': '100',
'Actual Charge Interval': '01/09/2026 - 30/09/2026',
'Charge Interval': 'monthly',
},
'csv:1',
)
assert normalized['charge_interval'] == '01/09/2026 - 30/09/2026'
@pytest.mark.parametrize('changes', [{'economic_product_number': None}, {'economic_connection_id': 2},
{'is_active_in_economic': False}, {'deleted_at': 'today'}, {'status': 'inactive'}])
def test_export_invalid_product_blocks(changes):
p = product()
p.update(changes)
with pytest.raises(HTTPException):
d.line_snapshot({'quantity':1,'unit_price':10}, p, 1, 'DKK')
def test_export_does_not_relabel_currency():
with pytest.raises(HTTPException):
d.line_snapshot({'quantity':1,'unit_price':10,'currency':'EUR'}, product(), 1, 'DKK')
@pytest.mark.parametrize('flag', ['ECONOMIC_READ_ONLY', 'ECONOMIC_DRY_RUN'])
@pytest.mark.parametrize('method', ['POST', 'PUT', 'PATCH', 'DELETE'])
def test_safety_prevents_network_for_every_write(monkeypatch, flag, method):
monkeypatch.setattr(c.settings, flag, True)
with pytest.raises(c.RemoteError) as error:
asyncio.run(c.EconomicClient().request(method, 'products', {}))
assert error.value.status == 423
@pytest.mark.parametrize('method,path', [('PUT','products/1'), ('PATCH','products/1'), ('DELETE','products/1'),
('POST','invoices/drafts'), ('POST','suppliers'), ('POST','journals/1/vouchers')])
def test_economic_write_policy_blocks_everything_except_create_customer_product_order(monkeypatch, method, path):
monkeypatch.setattr(c.settings, 'ECONOMIC_READ_ONLY', False)
monkeypatch.setattr(c.settings, 'ECONOMIC_DRY_RUN', False)
with pytest.raises(c.RemoteError) as error:
asyncio.run(c.EconomicClient().request(method, path, {}))
assert error.value.status == 403
@pytest.mark.parametrize('path', ['customers', 'products', 'orders/drafts'])
def test_economic_write_policy_allows_only_three_create_operations(path):
from app.core.economic_write_policy import assert_economic_write_allowed
assert_economic_write_allowed('POST', path)
def test_four_eyes_context_allows_only_customer_and_product_put():
from app.core.economic_write_policy import approved_four_eyes_write, assert_economic_write_allowed
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'customers/42')
with approved_four_eyes_write():
assert_economic_write_allowed('PUT', 'customers/42')
assert_economic_write_allowed('PUT', 'products/ABC-1')
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'orders/drafts/12')
with pytest.raises(HTTPException):
assert_economic_write_allowed('DELETE', 'products/ABC-1')
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'products/ABC-1')
def test_confirmed_order_context_allows_only_numeric_draft_put():
from app.core.economic_write_policy import approved_order_draft_update, assert_economic_write_allowed
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'orders/drafts/12')
with approved_order_draft_update():
assert_economic_write_allowed('PUT', 'orders/drafts/12')
with pytest.raises(HTTPException):
assert_economic_write_allowed('PUT', 'orders/12')
with pytest.raises(HTTPException):
assert_economic_write_allowed('DELETE', 'orders/drafts/12')
def test_order_update_payload_strips_read_only_fields_and_keeps_product():
from app.modules.orders.backend.economic_sync import build_update_payload
payload, _ = build_update_payload({
'date':'2026-09-13','currency':'DKK','customer':{'customerNumber':1},
'orderNumber':42,'netAmount':100,'lines':[{'lineNumber':1,'product':{'productNumber':'P1'},'quantity':1,'unitNetPrice':10}],
}, [{'description':'Ny','quantity':2,'unit_price':25,'discount_percentage':10}], 'Note')
assert 'orderNumber' not in payload and 'netAmount' not in payload
assert payload['lines'] == [{'product':{'productNumber':'P1'},'description':'Ny','quantity':2.0,'unitNetPrice':25.0,'discountPercentage':10.0}]
def test_four_eyes_change_field_allowlist():
from app.products.backend.economic_routes import _validate_change_fields
assert _validate_change_fields('product', {'name': 'Nyt navn'}) == {'name': 'Nyt navn'}
assert _validate_change_fields('customer', {'city': 'Værløse'}) == {'city': 'Værløse'}
with pytest.raises(HTTPException):
_validate_change_fields('product', {'productNumber': 'NYT'})
with pytest.raises(HTTPException):
_validate_change_fields('customer', {'customerNumber': 99})
def test_collection_paginates_and_rejects_loop(monkeypatch):
client = c.EconomicClient()
client.request = AsyncMock(side_effect=[{'collection':[1], 'pagination':{'nextPage':'products?skip=1'}}, {'collection':[2]}])
assert asyncio.run(client.collection('products')) == [1,2]
client.request = AsyncMock(return_value={'collection':[], 'pagination':{'nextPage':'products?pagesize=1000'}})
with pytest.raises(c.RemoteError):
asyncio.run(client.collection('products'))
def test_verify_uses_actual_self_schema(monkeypatch):
monkeypatch.setattr(c, 'query', lambda *a, **k: {'agreement_number':'123','currency':'DKK'})
client = c.EconomicClient()
client.request = AsyncMock(return_value={'agreementNumber':123,'settings':{'baseCurrency':'DKK'}})
assert asyncio.run(client.verify(1))['agreement_number'] == '123'
client.request = AsyncMock(return_value={'agreementNumber':999,'settings':{'baseCurrency':'DKK'}})
with pytest.raises(c.RemoteError):
asyncio.run(client.verify(1))
def test_export_duplicate_never_posts(monkeypatch):
monkeypatch.setattr(d, 'query', lambda *a, **kw: {'status':'verified','kind':'order','economic_number':'42','id':'abc'})
preflight = AsyncMock(side_effect=AssertionError('must not preflight or POST again'))
monkeypatch.setattr(d, 'preflight', preflight)
result = asyncio.run(d.export_document({'id':1}, 'order', 'same', 1, []))
assert result['economic_order_number'] == '42'
preflight.assert_not_called()
def test_uncertain_export_blocks_retry(monkeypatch):
monkeypatch.setattr(d, 'query', lambda *a, **kw: {'status':'uncertain'})
with pytest.raises(HTTPException):
asyncio.run(d.export_document({'id':1}, 'order', 'same', 1, []))
@pytest.fixture
def catalog_db(monkeypatch):
if os.environ.get('BMC_CATALOG_DB_TESTS') != '1':
pytest.skip('Opt-in local transactional database tests')
import psycopg2
from psycopg2.extras import RealDictCursor
conn = psycopg2.connect(c.settings.DATABASE_URL)
@contextmanager
def tx():
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute('SAVEPOINT catalog_test')
try:
yield cur
cur.execute('RELEASE SAVEPOINT catalog_test')
except Exception:
cur.execute('ROLLBACK TO SAVEPOINT catalog_test')
raise
monkeypatch.setattr(c, 'transaction', tx)
monkeypatch.setattr(d, 'transaction', tx)
try:
yield
finally:
conn.rollback()
conn.close()
def test_database_identity_outbox_import_and_rollback(catalog_db):
import psycopg2
connection = c.query("INSERT INTO economic_catalog_connections(agreement_number,name,currency) VALUES('catalog-test-'||gen_random_uuid(),'TEST','DKK') RETURNING *", one=True)
remote = {'productNumber':'00123','name':'Original','salesPrice':100,'productGroup':{'productGroupNumber':1},'unit':{'unitNumber':1}}
assert c.import_product(connection, remote) == 'created'
assert c.import_product(connection, remote) == 'unchanged'
p = c.query('SELECT * FROM products WHERE economic_connection_id=%s', (connection['id'],), one=True)
with pytest.raises(psycopg2.Error):
c.query("UPDATE products SET economic_product_number='changed' WHERE id=%s", (p['id'],))
c.query('UPDATE products SET name=%s WHERE id=%s', ('Hub edited', p['id']))
jobs = c.query("SELECT * FROM economic_catalog_jobs WHERE product_id=%s AND kind='name'", (p['id'],))
assert jobs == []
c.import_product(connection, remote)
current = c.query('SELECT * FROM products WHERE id=%s', (p['id'],), one=True)
assert current['name'] == 'Hub edited' and current['economic_sync_status'] == 'local_only'
c.import_product(connection, {**remote, 'name':'External edit'})
assert c.query('SELECT economic_sync_status FROM products WHERE id=%s', (p['id'],), one=True)['economic_sync_status'] == 'conflict'
def test_database_create_double_click_same_reservation(catalog_db):
connection = c.query("INSERT INTO economic_catalog_connections(agreement_number,name,currency) VALUES('catalog-test-'||gen_random_uuid(),'TEST','DKK') RETURNING *", one=True)
p = c.query("INSERT INTO products(name) VALUES('TEST') RETURNING id", one=True)
first = c.enqueue(connection['id'], 'create', None, p['id'], {'productGroup':{'productGroupNumber':1}})
second = c.enqueue(connection['id'], 'create', None, p['id'], {})
assert first == second
assert c.query('SELECT next_number FROM economic_catalog_connections WHERE id=%s', (connection['id'],), one=True)['next_number'] == 2
def test_database_timeout_is_uncertain_and_retry_cannot_post(catalog_db, monkeypatch):
connection = c.query("INSERT INTO economic_catalog_connections(agreement_number,name,currency) VALUES('catalog-test-'||gen_random_uuid(),'TEST','DKK') RETURNING *", one=True)
monkeypatch.setattr(d, 'preflight', AsyncMock(return_value={'payload':{},'lines':[]}))
request = AsyncMock(side_effect=asyncio.TimeoutError())
monkeypatch.setattr(c.EconomicClient, 'request', request)
with pytest.raises(HTTPException):
asyncio.run(d.export_document(connection,'order','timeout-test',1,[]))
row = c.query('SELECT * FROM economic_document_exports WHERE connection_id=%s', (connection['id'],), one=True)
assert row['status'] == 'uncertain'
with pytest.raises(HTTPException):
asyncio.run(d.export_document(connection,'order','timeout-test',1,[]))
assert request.call_count == 1
def test_database_local_name_change_does_not_queue_external_write(catalog_db, monkeypatch):
connection = c.query("INSERT INTO economic_catalog_connections(agreement_number,name,currency,enabled) VALUES('catalog-test-'||gen_random_uuid(),'TEST','DKK',true) RETURNING *", one=True)
c.import_product(connection, {'productNumber':'TEST-001','name':'Original','productGroup':{'productGroupNumber':1}})
p = c.query('SELECT * FROM products WHERE economic_connection_id=%s', (connection['id'],), one=True)
c.query("UPDATE products SET name='First' WHERE id=%s", (p['id'],))
c.query("UPDATE products SET name='Second' WHERE id=%s", (p['id'],))
assert not c.query("SELECT id FROM economic_catalog_jobs WHERE product_id=%s AND kind='name'", (p['id'],))
current = c.query('SELECT name,economic_sync_status FROM products WHERE id=%s', (p['id'],), one=True)
assert current == {'name':'Second','economic_sync_status':'local_only'}
def test_database_create_timeout_retains_reserved_number(catalog_db, monkeypatch):
connection = c.query("INSERT INTO economic_catalog_connections(agreement_number,name,currency,enabled) VALUES('catalog-test-'||gen_random_uuid(),'TEST','DKK',true) RETURNING *", one=True)
p = c.query("INSERT INTO products(name) VALUES('New test product') RETURNING id", one=True)
c.query("INSERT INTO economic_catalog_references(connection_id,kind,number,name,payload) VALUES(%s,'product-groups',1,'Test','{}')", (connection['id'],))
enqueued = c.enqueue(connection['id'], 'create', None, p['id'], {'productGroup':{'productGroupNumber':1}})
job = c.query('SELECT * FROM economic_catalog_jobs WHERE id=%s', (enqueued['id'],), one=True)
monkeypatch.setattr(c.EconomicClient, 'verify', AsyncMock(return_value=connection))
async def remote(method, path, payload=None):
if path.startswith('products/'):
raise c.RemoteError(404, 'not found')
if method == 'POST':
raise asyncio.TimeoutError()
return {'productGroupNumber':1}
monkeypatch.setattr(c.EconomicClient, 'request', staticmethod(remote))
with pytest.raises(asyncio.TimeoutError):
asyncio.run(c.process_job(job))
assert c.query('SELECT status FROM economic_catalog_jobs WHERE id=%s', (job['id'],), one=True)['status'] == 'uncertain'
assert c.enqueue(connection['id'], 'create', None, p['id'], {}) == enqueued
def test_database_paging_checkpoint_survives_interrupted_import(catalog_db, monkeypatch):
connection = c.query("INSERT INTO economic_catalog_connections(agreement_number,name,currency) VALUES('catalog-test-'||gen_random_uuid(),'TEST','DKK') RETURNING *", one=True)
enqueued = c.enqueue(connection['id'], 'import', None)
job = c.query('SELECT * FROM economic_catalog_jobs WHERE id=%s', (enqueued['id'],), one=True)
monkeypatch.setattr(c.EconomicClient, 'verify', AsyncMock(return_value=connection))
seen = []
fail = True
async def remote(method, path, payload=None):
seen.append(path)
if path == 'products?pagesize=1000':
return {'collection':[{'productNumber':'TEST-01','name':'Test','productGroup':{'productGroupNumber':1}}], 'pagination':{'nextPage':'products?skip=1'}}
if path == 'products?skip=1' and fail:
raise c.RemoteError(503, 'interrupted')
return {'collection':[]}
monkeypatch.setattr(c.EconomicClient, 'request', staticmethod(remote))
with pytest.raises(c.RemoteError):
asyncio.run(c.process_job(job))
assert not c.query('SELECT id FROM products WHERE economic_connection_id=%s', (connection['id'],))
fail = False
result = asyncio.run(c.process_job(job))
assert result['created'] == 1
assert seen.count('products?pagesize=1000') == 1
def test_manual_price_requires_permission_but_saved_contract_is_preserved(monkeypatch):
from app.products.backend import economic_pricing as pricing
monkeypatch.setattr(pricing, 'query', lambda sql,*a,**k: [] if 'economic_price_rules' in sql else product())
line = {'line_key':'a','product_id':12,'unit_price':55,'discount_percentage':0}
with pytest.raises(HTTPException) as exc:
pricing.validate_manual_prices([line], 2, False)
assert exc.value.status_code == 403
pricing.validate_manual_prices([line], 2, False, [line])
pricing.validate_manual_prices([line], 2, True)
def test_cost_filter_covers_nested_response_and_history():
from app.products.backend.economic_access import without_cost
result = without_cost({'cost_price':25, 'name':'Test', 'sales_price':100,
'economic_snapshot':{'costPrice':40}, 'nested':{'unitCostPrice':20},
'history':[{'price_type':'supplier_price','old_price':10,'new_price':20}, {'price_type':'sales_price','new_price':100}]})
assert result == {'name':'Test','sales_price':100,'nested':{},'history':[{'price_type':'sales_price','new_price':100}]}
def test_catalog_api_requires_authentication():
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.products.backend.economic_routes import router
app = FastAPI()
app.include_router(router)
assert TestClient(app).get('/economic/catalog/status').status_code == 401
assert TestClient(app).post('/products/1/economic-create', json={'connection_id':1,'group_number':1}).status_code == 401
def test_catalog_reader_cannot_create_product(monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core.auth_dependencies import get_current_user
from app.core.auth_service import AuthService
from app.products.backend.economic_routes import router
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: {'id':1,'username':'reader','is_superadmin':False}
monkeypatch.setattr(AuthService, 'user_has_permission', lambda uid, permission: permission == 'economic.catalog.view')
assert TestClient(app).post('/products/1/economic-create', json={'connection_id':1,'group_number':1}).status_code == 403

View File

@ -0,0 +1,106 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_quick_setup_creates_draft_subscription_and_links_existing_connection(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
from app.subscriptions.backend import router as subscription_router
def fake_query_single(sql, params=None):
if "FROM customers" in sql:
return {"id": 7, "name": "Testkunde"}
if "FROM products" in sql:
return {
"id": 11,
"name": "Internet 1000/1000",
"short_description": "Fiber internet",
"sales_price": 799,
"attributes_json": {"network": {"kind": "internet_access"}},
"type": "service",
}
if "FROM internet_connections_connections" in sql:
return {"id": 23, "name": "GC kredsløb", "customer_id": None, "subscription_id": None}
if "INSERT INTO sag_sager" in sql:
return {"id": 31, "titel": "Internet - Testkunde", "customer_id": 7}
return None
async def fake_create_subscription(payload, current_user=None):
assert payload["sag_id"] == 31
assert payload["line_items"][0]["product_id"] == 11
return {"id": 41, "status": "draft"}
async def fake_update_connection(connection_id, payload):
assert connection_id == 23
assert payload.subscription_id == 41
assert payload.customer_id == 7
return {"id": 23, "subscription_id": 41, "customer_id": 7}
monkeypatch.setattr(internet_router, "execute_query_single", fake_query_single)
monkeypatch.setattr(
internet_router,
"build_network_product_profile",
lambda product, fallback_text=None: {"kind": "internet_access"},
)
monkeypatch.setattr(internet_router, "_resolve_group_id_by_name_tokens", lambda tokens: 3)
monkeypatch.setattr(internet_router, "update_connection", fake_update_connection)
monkeypatch.setattr(subscription_router, "create_subscription", fake_create_subscription)
app = FastAPI()
app.include_router(internet_router.router, prefix="/api/v1")
response = TestClient(app).post(
"/api/v1/internet-connections/quick-setup",
json={
"customer_id": 7,
"product_id": 11,
"start_date": "2026-10-01",
"connection_id": 23,
"billing_interval": "monthly",
"billing_day": 1,
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["subscription"]["status"] == "draft"
assert body["connection"]["subscription_id"] == 41
def test_quick_setup_shared_fiber_does_not_require_or_create_subscription(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_query_single(sql, params=None):
if "LOWER(TRIM(c.name))" in sql:
return {"id": 1662, "name": "BMC Networks"}
if "SELECT id, name FROM customers" in sql:
return {"id": 1662, "name": "BMC Networks"}
return None
async def fake_create_connection(payload):
assert payload.customer_id == 1662
assert payload.allocation_model == "shared"
assert payload.value_type == "delefiber"
assert payload.is_manual_shared is True
assert payload.subscription_id is None
return {"id": 88, "name": payload.name, "customer_id": payload.customer_id}
monkeypatch.setattr(internet_router, "execute_query_single", fake_query_single)
monkeypatch.setattr(internet_router, "create_connection", fake_create_connection)
app = FastAPI()
app.include_router(internet_router.router, prefix="/api/v1")
response = TestClient(app).post(
"/api/v1/internet-connections/quick-setup",
json={
"shared_fiber": True,
"connection_name": "Delt fiber - Testvej 1",
"vendor_id": 2,
"address": "Testvej 1",
"monthly_cost": 1500,
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["sag"] is None
assert body["subscription"] is None
assert body["connection"]["id"] == 88

View File

@ -0,0 +1,104 @@
from app.billing.backend.supplier_invoices import (
_build_template_builder_result,
_hybrid_validate_invoice,
_smart_extract_lines,
)
DCS_TEXT = """
Nr. Varenr Tekst Model Antal Pris Beløb-
1 1003550858 Apple iPhone 15 6,1 128GB - Blå - Grade A NP/IPH/15/128/BLUE/A 1 3.149,00 3.149,00
Omvendt betalingspligt*
KN8: 84713000
EAN: 5711802732536
S/N: MQJYVVF7TJ
2 1001562408 PanzerGlass SAFE. by TPU Case Transparent SAFE95538 1 49,00 49,00
3 1002365988 SAFE. by PanzerGlass Apple iPhone 16, 15 | Ul SAFE95875 4 49,00 196,00
4 1002484222 Apple iPhone 16 6,1 128GB - Sort MYE73QN/A 1 5.099,00 5.099,00
Omvendt betalingspligt*
5 1002365899 PanzerGlass CARE by Feature Case Transparent 1325 1 59,00 59,00
6 1000775307 Huawei E3372-325 Trådløs mobilmodem Trådløs 51071UXG 1 189,00 189,00
7 1003143468 Apple 11-inch iPad Air M3 Wi-Fi 11 128GB 8GB MC9W4KN/A 2 4.299,00 8.598,00
Omvendt betalingspligt*
8 68678 PostNord - Erhverv - Fast fragt 40 - DK - 1-5 1 40,00 40,00
Varebeløb Momsgrundlag Moms Momssats Totalbeløb DKK
17.379,00 533,00 133,25 25,00% 17.512,25
"""
def test_dcs_layout_extracts_all_lines_and_metadata():
lines = _smart_extract_lines(DCS_TEXT)
assert len(lines) == 8
assert lines[0]["unit_price"] == 3149.0
assert lines[0]["ean"] == "5711802732536"
assert lines[0]["serial_numbers"] == ["MQJYVVF7TJ"]
assert lines[0]["reverse_charge"] is True
assert lines[6]["line_total"] == 8598.0
def test_hybrid_validation_understands_reverse_charge_summary():
result = _hybrid_validate_invoice({"lines": [], "total_amount": "17.512,25"}, DCS_TEXT)
validation = result["_hybrid_validation"]
assert validation["status"] == "validated"
assert validation["line_count"] == 8
assert validation["line_sum"] == 17379.0
assert validation["document_totals"]["vat_basis"] == 533.0
assert result["total_amount"] == 17512.25
def test_generic_layout_accepts_alphanumeric_supplier_sku_and_english_amounts():
text = "1 ABC-123 Managed router service 2 1,250.00 2,500.00"
lines = _smart_extract_lines(text)
assert len(lines) == 1
assert lines[0]["sku"] == "ABC-123"
assert lines[0]["quantity"] == 2.0
assert lines[0]["unit_price"] == 1250.0
assert lines[0]["line_total"] == 2500.0
def test_template_builder_overrides_wrong_ai_values_and_excludes_buyer_identity():
text = """
Faktura
Nummer 5055616
Dato 2/10-25
Momsnr. DK29522790
BMC Denmark ApS
Lejrvej 39
Nr. Varenr Tekst Model Antal Pris Beløb-
1 1000822111 Apple TV 4K MN893MP/A 1 1.099,00 1.099,00
2 1000809070 Xiaomi Air Purifier BHR5860EU 2 499,00 998,00
3 1003114298 Lenovo ThinkPad L-T14G2-SCA-B102 1 1.999,00 1.999,00
4 1003283728 HPE SmartMemory RFB-815101-B21 2 569,00 1.138,00
5 68678 PostNord Erhverv Fast fragt 1 40,00 40,00
Varebeløb Momsgrundlag Moms Momssats Totalbeløb DKK
5.274,00 3.275,00 818,75 25,00% 6.092,75
DCS ApS CVR: DK26686091 www.dcs.dk
"""
bad_ai_result = {
"total_amount": "2097",
"cvr": "29522790",
"detection_patterns": ["BMC Denmark ApS", "Lejrvej 39", "3500 Værløse"],
}
result = _build_template_builder_result(
text,
bad_ai_result,
{"name": "DCS ApS", "cvr_number": "26686091"},
)
assert result["invoice_number"]["value"] == "5055616"
assert result["invoice_date"]["value"] == "2/10-25"
assert result["total_amount"]["value"] == "6092.75"
assert result["cvr"]["value"] == "26686091"
assert "BMC Denmark ApS" not in result["detection_patterns"]
assert "Lejrvej 39" not in result["detection_patterns"]
assert "DCS ApS" in result["detection_patterns"]
assert result["line_count"] == 5
for field in ("invoice_number", "invoice_date", "total_amount", "cvr"):
match = __import__("re").search(result[field]["pattern"], text, __import__("re").IGNORECASE | __import__("re").MULTILINE)
assert match, field

View File

@ -0,0 +1,19 @@
from app.services.ollama_service import OllamaService
def test_normalizes_numeric_and_danish_amount_values():
normalize = OllamaService._normalize_amount_value
assert normalize(1471.2) == 1471.2
assert normalize("1471.20") == 1471.2
assert normalize("1.471,20 DKK") == 1471.2
assert normalize("-1 471,20 kr.") == -1471.2
def test_rejects_non_amount_values_without_type_errors():
normalize = OllamaService._normalize_amount_value
assert normalize(None) is None
assert normalize("") is None
assert normalize("ukendt") is None
assert normalize({"value": "10,00"}) is None

View File

@ -0,0 +1,108 @@
def test_order_export_inherits_global_safety_flags_when_order_flags_are_unset(monkeypatch):
from app.modules.orders.backend import economic_export
settings = economic_export.settings
monkeypatch.setattr(settings, "ECONOMIC_READ_ONLY", False)
monkeypatch.setattr(settings, "ECONOMIC_DRY_RUN", False)
monkeypatch.setattr(settings, "ORDRE_ECONOMIC_READ_ONLY", True)
monkeypatch.setattr(settings, "ORDRE_ECONOMIC_DRY_RUN", True)
fields_set = set(settings.model_fields_set)
fields_set.discard("ORDRE_ECONOMIC_READ_ONLY")
fields_set.discard("ORDRE_ECONOMIC_DRY_RUN")
monkeypatch.setattr(settings, "__pydantic_fields_set__", fields_set)
service = economic_export.OrdreEconomicExportService()
assert service.read_only is False
assert service.dry_run is False
def test_order_export_respects_explicit_order_safety_flags(monkeypatch):
from app.modules.orders.backend import economic_export
settings = economic_export.settings
monkeypatch.setattr(settings, "ECONOMIC_READ_ONLY", False)
monkeypatch.setattr(settings, "ECONOMIC_DRY_RUN", False)
monkeypatch.setattr(settings, "ORDRE_ECONOMIC_READ_ONLY", True)
monkeypatch.setattr(settings, "ORDRE_ECONOMIC_DRY_RUN", True)
fields_set = set(settings.model_fields_set)
fields_set.update({"ORDRE_ECONOMIC_READ_ONLY", "ORDRE_ECONOMIC_DRY_RUN"})
monkeypatch.setattr(settings, "__pydantic_fields_set__", fields_set)
service = economic_export.OrdreEconomicExportService()
assert service.read_only is True
assert service.dry_run is True
def test_economic_validation_error_is_exposed_without_developer_metadata():
from app.modules.orders.backend.economic_export import _economic_error_message
message = _economic_error_message(
400,
'{"message":"Validation failed","errors":{"paymentTerms":"Required"},"developerHint":"internal"}',
)
assert "Validation failed" in message
assert "paymentTerms" in message
assert "Required" in message
assert "internal" not in message
def test_order_export_blocks_priced_line_when_product_does_not_exist():
from fastapi import HTTPException
from app.modules.orders.backend.economic_export import _require_valid_product_numbers
try:
_require_valid_product_numbers({"1000", "UKENDT"}, {"1000"})
assert False, "missing product must block the export before POST"
except HTTPException as exc:
assert exc.status_code == 409
assert "UKENDT" in exc.detail
assert "Ordren blev ikke sendt" in exc.detail
def test_order_export_accepts_only_verified_products():
from app.modules.orders.backend.economic_export import _require_valid_product_numbers
assert _require_valid_product_numbers({"1000"}, {"1000"}) == []
def test_product_creation_preview_values_become_economic_payload():
from app.modules.orders.backend.economic_export import _product_creation_payload
payload = _product_creation_payload({
"product_number": "W2211X",
"name": "HP 207X Cyan",
"description": "Original toner",
"ean": "0194850012345",
"sales_price": 899.95,
}, 2)
assert payload == {
"productNumber": "W2211X",
"name": "HP 207X Cyan",
"description": "Original toner",
"salesPrice": 899.95,
"barCode": "0194850012345",
"barred": False,
"productGroup": {"productGroupNumber": 2},
}
def test_legacy_draft_id_is_saved_as_order_number():
from app.modules.orders.backend.router import _economic_order_number
assert _economic_order_number({"economic_draft_id": 4711}) == 4711
def test_exported_status_requires_economic_order_number():
from fastapi import HTTPException
from app.modules.orders.backend.router import _validate_sync_status_number
try:
_validate_sync_status_number("exported", None, None)
assert False, "exported status without an external order number must be rejected"
except HTTPException as exc:
assert exc.status_code == 409
_validate_sync_status_number("exported", None, "4711")