Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee274a939a | ||
|
|
b95815de0d | ||
|
|
dbdb1c554a | ||
|
|
85fd99c5d8 | ||
|
|
4971f9721c | ||
|
|
a523bc4ac3 | ||
|
|
2a71e9285a | ||
|
|
b3059dced1 | ||
|
|
87932f8e16 | ||
|
|
3e8e5ba1cf | ||
|
|
631fc6cdca | ||
|
|
13f36a36c0 | ||
|
|
7c1a748012 | ||
|
|
3d4a7c8421 | ||
|
|
f251bfebc4 | ||
|
|
056ce7b871 | ||
|
|
4bf592cf0c |
39
MDfile/RELEASE_NOTES_v2.8.1.md
Normal file
39
MDfile/RELEASE_NOTES_v2.8.1.md
Normal file
@ -0,0 +1,39 @@
|
||||
# BMC Hub v2.8.1
|
||||
|
||||
## Planner
|
||||
|
||||
- Nyt Planner-modul til fordeling af sager og overblik over teamets kapacitet.
|
||||
- Dag-, uge- og månedsvisning med planlagte blokke, registreret tid og ekstern travl tid.
|
||||
- Drag-and-drop af sager med kompakt forhåndsvisning, 15-minutters placering og håndterbare standardblokke.
|
||||
- Bredere sagsoversigt og forbedret pladsudnyttelse i kalenderen.
|
||||
- Kalender og Planlægger er samlet i en Kalender-undermenu.
|
||||
|
||||
## Systemlog og drift
|
||||
|
||||
- Ny administrativ systemlog til telefoni, e-mail og øvrige integrationshændelser.
|
||||
- Udvidet hændelseslogning og adgangskontrol i indstillingerne.
|
||||
|
||||
## AI og fakturaskabeloner
|
||||
|
||||
- Rettet AI Auto-generer i Template Builder.
|
||||
- Tilføjet manglende `OWN_CVR`-konfiguration og validering af Ollama-fejl.
|
||||
- Ollama fakturaudtræk tvinges til struktureret JSON.
|
||||
- GUI'en viser nu den konkrete AI-fejl i stedet for en generisk fejlbesked.
|
||||
|
||||
## Integrationer og brugerflade
|
||||
|
||||
- Forbedringer til telefoni, e-mail og ALSO Cloud-integrationerne.
|
||||
- Mindre rettelser til login, indstillinger og den fælles navigation.
|
||||
- Fremtidige udviklingsplaner har fået et fast dokumentationsområde.
|
||||
|
||||
## Database
|
||||
|
||||
- Kør `migrations/240_planner.sql`.
|
||||
- Kør `migrations/241_system_event_log.sql`.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Planner: 8 tests bestået.
|
||||
- Samlet testsuite: 271 bestået, 1 sprunget over.
|
||||
- Fire eksisterende tests kræver databaseinitialisering eller har forældede invoice-history mocks.
|
||||
- Python- og JavaScript-syntakskontrol bestået.
|
||||
11
MDfile/RELEASE_NOTES_v2.8.10.md
Normal file
11
MDfile/RELEASE_NOTES_v2.8.10.md
Normal 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.
|
||||
6
MDfile/RELEASE_NOTES_v2.8.11.md
Normal file
6
MDfile/RELEASE_NOTES_v2.8.11.md
Normal 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.
|
||||
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.14.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.14.md
Normal 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.
|
||||
11
MDfile/RELEASE_NOTES_v2.8.15.md
Normal file
11
MDfile/RELEASE_NOTES_v2.8.15.md
Normal 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.
|
||||
9
MDfile/RELEASE_NOTES_v2.8.16.md
Normal file
9
MDfile/RELEASE_NOTES_v2.8.16.md
Normal 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.2.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.2.md
Normal 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.
|
||||
38
MDfile/RELEASE_NOTES_v2.8.3.md
Normal file
38
MDfile/RELEASE_NOTES_v2.8.3.md
Normal 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.4.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.4.md
Normal 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.5.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.5.md
Normal 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.6.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.6.md
Normal 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.
|
||||
21
MDfile/RELEASE_NOTES_v2.8.7.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.7.md
Normal 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.
|
||||
17
MDfile/RELEASE_NOTES_v2.8.8.md
Normal file
17
MDfile/RELEASE_NOTES_v2.8.8.md
Normal 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.
|
||||
14
MDfile/RELEASE_NOTES_v2.8.9.md
Normal file
14
MDfile/RELEASE_NOTES_v2.8.9.md
Normal 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.
|
||||
@ -87,6 +87,11 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const requestedNext = new URLSearchParams(window.location.search).get('next');
|
||||
const loginRedirectTarget = requestedNext && requestedNext.startsWith('/') && !requestedNext.startsWith('//')
|
||||
? requestedNext
|
||||
: '/';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -131,8 +136,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = loginRedirectTarget;
|
||||
} else {
|
||||
// Show error
|
||||
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
|
||||
@ -169,8 +173,7 @@ if (token) {
|
||||
d.setTime(d.getTime() + (24*60*60*1000));
|
||||
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = loginRedirectTarget;
|
||||
} else {
|
||||
// Token invalid, clear storage
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
@ -2243,6 +2243,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):
|
||||
@ -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 rå data
|
||||
|
||||
PDF TEKST:
|
||||
{pdf_text[:2000]}
|
||||
{excerpt}
|
||||
|
||||
RETURNER KUN JSON - intet andet!"""
|
||||
|
||||
@ -3700,10 +3945,21 @@ RETURNER KUN JSON - intet andet!"""
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen")
|
||||
if result.get("error"):
|
||||
# extract_from_text returns operational Ollama failures as a dict.
|
||||
# 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
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI analyse fejlede: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}")
|
||||
@ -4910,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)
|
||||
@ -4986,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
|
||||
@ -5061,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
|
||||
@ -5122,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
|
||||
|
||||
@ -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>
|
||||
` : ''}
|
||||
|
||||
|
||||
@ -206,6 +206,8 @@
|
||||
2. Eller markér tekst manuelt og vælg felttype<br>
|
||||
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
|
||||
@ -1370,10 +1372,20 @@ async function autoGenerateTemplate() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('AI analyse fejlede');
|
||||
let detail = `AI analyse fejlede (HTTP ${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload?.detail) detail = payload.detail;
|
||||
} catch (_) {
|
||||
// Keep the HTTP status when the server did not return JSON.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// Helper to extract value (handles both nested {value, pattern} and flat formats)
|
||||
const getValue = (field) => field?.value || field;
|
||||
@ -1456,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!';
|
||||
@ -1468,10 +1481,61 @@ async function autoGenerateTemplate() {
|
||||
console.error('AI generation failed:', error);
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
alert('AI analyse fejlede. Prøv manuel indtastning.');
|
||||
alert(`AI analyse fejlede: ${error.message || 'Ukendt fejl'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function templateEscapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -123,6 +123,9 @@ class Settings(BaseSettings):
|
||||
# Ollama LLM
|
||||
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
|
||||
OLLAMA_MODEL: str = "llama3.2"
|
||||
# Primary company CVR used to prevent AI invoice extraction from treating
|
||||
# the buyer (BMC) as the supplier.
|
||||
OWN_CVR: str = "29522790"
|
||||
|
||||
# Email System Configuration
|
||||
# IMAP Settings
|
||||
|
||||
59
app/core/economic_write_policy.py
Normal file
59
app/core/economic_write_policy.py
Normal 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',
|
||||
)
|
||||
@ -488,6 +488,11 @@
|
||||
|
||||
const fetchJson = async (url, options = {}) => {
|
||||
const res = await fetch(url, options);
|
||||
if (res.status === 401) {
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
|
||||
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let message = text || `HTTP ${res.status}`;
|
||||
@ -525,7 +530,7 @@
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
const config = await fetchJson('/api/v1/also/config');
|
||||
const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' });
|
||||
const label = !config.enabled
|
||||
? 'ALSO deaktiveret'
|
||||
: config.read_only
|
||||
@ -667,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 `
|
||||
@ -694,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'}>
|
||||
@ -715,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 `
|
||||
@ -742,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'}>
|
||||
@ -819,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) => {
|
||||
@ -868,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) => {
|
||||
|
||||
@ -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,52 +1147,58 @@
|
||||
? '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 = [];
|
||||
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
|
||||
if (row.effect) effects.push(row.effect);
|
||||
});
|
||||
(preview.matching_workflows || []).forEach((workflow) => {
|
||||
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
|
||||
actions.forEach((action) => effects.push(
|
||||
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
|
||||
));
|
||||
});
|
||||
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);
|
||||
});
|
||||
(preview.matching_workflows || []).forEach((workflow) => {
|
||||
const actions = Array.isArray(workflow.actions) ? workflow.actions : [];
|
||||
actions.forEach((action) => effects.push(
|
||||
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="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>
|
||||
<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>
|
||||
<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>
|
||||
${renderIdentityAssessment()}
|
||||
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
|
||||
${hasActions ? `
|
||||
<ol class="email-ai-actions mb-3">
|
||||
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
|
||||
</ol>
|
||||
${automaticExecution ? `
|
||||
<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:' : 'Flowet udfører:'}</div>
|
||||
${hasActions ? `
|
||||
<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 {
|
||||
await executeWorkflowsCurrent();
|
||||
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,25 +1698,47 @@
|
||||
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>
|
||||
`;
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Query, Request, Response, UploadFile
|
||||
|
||||
from app.modules.also.backend.service import also_service
|
||||
from app.modules.also.models.schemas import (
|
||||
@ -34,7 +34,8 @@ def _user_id_from_request(request: Request) -> Optional[int]:
|
||||
|
||||
|
||||
@router.get("/also/config")
|
||||
async def also_config() -> dict:
|
||||
async def also_config(response: Response) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return also_service.get_config()
|
||||
|
||||
|
||||
|
||||
@ -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"}),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
@ -386,6 +413,52 @@
|
||||
</table>
|
||||
</div>
|
||||
</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">
|
||||
@ -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()]);
|
||||
|
||||
@ -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,47 +214,170 @@ 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)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"date": date.today().isoformat(),
|
||||
"currency": "DKK",
|
||||
"customer": {
|
||||
"customerNumber": int(customer["economic_customer_number"]),
|
||||
},
|
||||
"layout": {
|
||||
"layoutNumber": int(layout_number or self.default_layout),
|
||||
},
|
||||
"lines": economic_lines,
|
||||
}
|
||||
|
||||
if notes:
|
||||
payload["notes"] = {"textLine1": str(notes)[:250]}
|
||||
|
||||
operation = f"Export ordre for customer {customer_id} to e-conomic"
|
||||
if not self._check_write_permission(operation):
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"message": "DRY-RUN: Export blocked by safety flags",
|
||||
"details": {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name"),
|
||||
"selected_line_count": len(selected_lines),
|
||||
"read_only": self.read_only,
|
||||
"dry_run": self.dry_run,
|
||||
"user_id": user_id,
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
|
||||
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": 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(resolved_layout_number),
|
||||
},
|
||||
"lines": economic_lines,
|
||||
}
|
||||
|
||||
if notes:
|
||||
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]}
|
||||
|
||||
if not write_allowed:
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"message": "DRY-RUN: Export blocked by safety flags",
|
||||
"details": {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name"),
|
||||
"selected_line_count": len(selected_lines),
|
||||
"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))
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
182
app/modules/orders/backend/economic_sync.py
Normal file
182
app/modules/orders/backend/economic_sync.py
Normal 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)}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
? `
|
||||
|
||||
@ -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;
|
||||
document.getElementById('orderStatus').innerHTML = hasExported
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
1
app/modules/planner/__init__.py
Normal file
1
app/modules/planner/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Case planning and capacity module."""
|
||||
1
app/modules/planner/backend/__init__.py
Normal file
1
app/modules/planner/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Planner API package."""
|
||||
34
app/modules/planner/backend/providers.py
Normal file
34
app/modules/planner/backend/providers.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Provider-neutral, read-only busy-time interface."""
|
||||
from typing import Protocol
|
||||
|
||||
from app.core.database import execute_query
|
||||
|
||||
|
||||
class BusyTimeProvider(Protocol):
|
||||
def get_busy(self, user_ids: list[int], starts_at, ends_at) -> list[dict]: ...
|
||||
def statuses(self, user_ids: list[int]) -> list[dict]: ...
|
||||
|
||||
|
||||
class DatabaseBusyTimeProvider:
|
||||
"""Cache adapter used by Outlook/Google sync jobs without exposing meeting data."""
|
||||
|
||||
def get_busy(self, user_ids, starts_at, ends_at):
|
||||
if not user_ids:
|
||||
return []
|
||||
return execute_query(
|
||||
"""SELECT user_id, starts_at, ends_at, provider FROM planner_external_busy
|
||||
WHERE user_id = ANY(%s) AND starts_at < %s AND ends_at > %s""",
|
||||
(user_ids, ends_at, starts_at),
|
||||
) or []
|
||||
|
||||
def statuses(self, user_ids):
|
||||
if not user_ids:
|
||||
return []
|
||||
return execute_query(
|
||||
"""SELECT user_id, provider, status, last_success_at, last_error_at
|
||||
FROM planner_integrations WHERE user_id = ANY(%s)""",
|
||||
(user_ids,),
|
||||
) or []
|
||||
|
||||
|
||||
busy_time_provider: BusyTimeProvider = DatabaseBusyTimeProvider()
|
||||
460
app/modules/planner/backend/router.py
Normal file
460
app/modules/planner/backend/router.py
Normal file
@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from psycopg2.extras import RealDictCursor, Json
|
||||
|
||||
from app.core.auth_dependencies import get_current_user
|
||||
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
|
||||
from .providers import busy_time_provider
|
||||
|
||||
router = APIRouter(prefix="/planner")
|
||||
TZ = ZoneInfo("Europe/Copenhagen")
|
||||
CLOSED_STATUSES = {"afsluttet", "lukket", "closed", "done", "arkiveret", "archived"}
|
||||
|
||||
|
||||
class AllocationInput(BaseModel):
|
||||
sag_id: int
|
||||
user_id: int
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
source: str = Field(default="manual", max_length=40)
|
||||
confirm_conflicts: bool = False
|
||||
locked: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_period(self):
|
||||
if self.ends_at <= self.starts_at:
|
||||
raise ValueError("Sluttid skal ligge efter starttid")
|
||||
return self
|
||||
|
||||
|
||||
class AllocationUpdate(BaseModel):
|
||||
user_id: int | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
version: int
|
||||
confirm_conflicts: bool = False
|
||||
locked: bool | None = None
|
||||
|
||||
|
||||
class CaseEstimateInput(BaseModel):
|
||||
estimated_minutes: int | None = Field(default=None, ge=0, le=525600)
|
||||
|
||||
|
||||
class PlannerSettingsInput(BaseModel):
|
||||
warning_percent: int = Field(ge=1, le=99)
|
||||
full_percent: int = Field(ge=100, le=300)
|
||||
default_allocation_minutes: int = Field(ge=15, le=1440)
|
||||
|
||||
|
||||
def _permissions(user: dict) -> set[str]:
|
||||
return set(user.get("permissions") or [])
|
||||
|
||||
|
||||
def _can_view(user: dict) -> bool:
|
||||
return bool(user.get("is_superadmin") or "planner.view" in _permissions(user))
|
||||
|
||||
|
||||
def _can_edit(user: dict, target_id: int) -> bool:
|
||||
if user.get("is_superadmin") or "planner.admin" in _permissions(user):
|
||||
return True
|
||||
if target_id == int(user["id"]) and "planner.edit_own" in _permissions(user):
|
||||
return True
|
||||
if "planner.edit_team" not in _permissions(user):
|
||||
return False
|
||||
return bool(execute_query_single(
|
||||
"SELECT 1 FROM planner_team_scope WHERE editor_user_id=%s AND target_user_id=%s",
|
||||
(user["id"], target_id),
|
||||
))
|
||||
|
||||
|
||||
def _require_view(user: dict):
|
||||
if not _can_view(user):
|
||||
raise HTTPException(403, "Du har ikke adgang til Planlæggeren")
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=TZ) if value.tzinfo is None else value
|
||||
|
||||
|
||||
def _minutes(start, end) -> int:
|
||||
return max(0, round((end - start).total_seconds() / 60))
|
||||
|
||||
|
||||
def _merge_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
|
||||
clipped = sorted((max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
|
||||
merged: list[list[datetime]] = []
|
||||
for a, b in clipped:
|
||||
if not merged or a > merged[-1][1]:
|
||||
merged.append([a, b])
|
||||
else:
|
||||
merged[-1][1] = max(merged[-1][1], b)
|
||||
return sum(_minutes(a, b) for a, b in merged)
|
||||
|
||||
|
||||
def _sum_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
|
||||
"""Count every allocation; overlapping jobs each consume assigned capacity."""
|
||||
return sum(_minutes(max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
|
||||
|
||||
|
||||
def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, allocations=None) -> list[dict]:
|
||||
settings = execute_query_single("SELECT * FROM planner_settings WHERE id=1") or {
|
||||
"warning_percent": 80, "full_percent": 100
|
||||
}
|
||||
schedules = execute_query(
|
||||
"""SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active
|
||||
AND (valid_from IS NULL OR valid_from <= %s) AND (valid_to IS NULL OR valid_to >= %s)""",
|
||||
(user_ids, ends_at.date(), starts_at.date()),
|
||||
) or []
|
||||
absences = execute_query(
|
||||
"SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
|
||||
(user_ids, ends_at, starts_at),
|
||||
) or []
|
||||
external = busy_time_provider.get_busy(user_ids, starts_at, ends_at)
|
||||
registered = execute_query(
|
||||
"""SELECT t.medarbejder_id AS user_id,
|
||||
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS registered_at,
|
||||
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes
|
||||
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
|
||||
WHERE t.medarbejder_id=ANY(%s)
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
|
||||
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)""",
|
||||
(user_ids, starts_at, ends_at, list(CLOSED_STATUSES)),
|
||||
) or []
|
||||
if allocations is None:
|
||||
allocations = execute_query(
|
||||
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
|
||||
(user_ids, ends_at, starts_at),
|
||||
) or []
|
||||
result = []
|
||||
current = starts_at.astimezone(TZ).date()
|
||||
final = (ends_at - timedelta(microseconds=1)).astimezone(TZ).date()
|
||||
schedule_map = {(r["user_id"], r["weekday"]): r for r in schedules}
|
||||
while current <= final:
|
||||
for uid in user_ids:
|
||||
row = schedule_map.get((uid, current.weekday()))
|
||||
if row:
|
||||
day_start = datetime.combine(current, row["start_time"], TZ)
|
||||
day_end = datetime.combine(current, row["end_time"], TZ)
|
||||
gross = _minutes(day_start, day_end)
|
||||
unavailable = _merge_minutes(
|
||||
[( _aware(r["starts_at"]), _aware(r["ends_at"]) ) for r in absences + external if r["user_id"] == uid],
|
||||
day_start, day_end,
|
||||
)
|
||||
available = max(0, gross - int(row["break_minutes"] or 0) - unavailable)
|
||||
else:
|
||||
day_start = datetime.combine(current, time(0), TZ)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
available = 0
|
||||
allocated = _sum_minutes(
|
||||
[(_aware(r["starts_at"]), _aware(r["ends_at"])) for r in allocations if r["user_id"] == uid],
|
||||
day_start, day_end,
|
||||
)
|
||||
percent = round(allocated * 100 / available) if available else (100 if allocated else 0)
|
||||
registered_minutes = sum(
|
||||
int(r.get("minutes") or 0) for r in registered
|
||||
if r["user_id"] == uid and _aware(r["registered_at"]).astimezone(TZ).date() == current
|
||||
)
|
||||
state = "gray" if available == 0 else (
|
||||
"red" if percent >= settings["full_percent"] else
|
||||
"yellow" if percent >= settings["warning_percent"] else "green"
|
||||
)
|
||||
result.append({"user_id": uid, "date": current.isoformat(), "available_minutes": available,
|
||||
"allocated_minutes": allocated, "percent": percent, "state": state,
|
||||
"registered_minutes": registered_minutes,
|
||||
"label": f"{allocated / 60:g} af {available / 60:g} timer planlagt",
|
||||
"registered_label": f"{registered_minutes / 60:g} timer registreret"})
|
||||
current += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
def _conflicts(user_id, starts_at, ends_at, exclude_id=None) -> tuple[str, dict]:
|
||||
query = """SELECT id FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s
|
||||
AND starts_at < %s AND ends_at > %s"""
|
||||
params: list = [user_id, ends_at, starts_at]
|
||||
if exclude_id:
|
||||
query += " AND id <> %s"
|
||||
params.append(exclude_id)
|
||||
overlaps = execute_query(query, tuple(params)) or []
|
||||
prospective = (execute_query(
|
||||
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s AND starts_at<%s AND ends_at>%s" + (" AND id<>%s" if exclude_id else ""),
|
||||
tuple([user_id, ends_at, starts_at] + ([exclude_id] if exclude_id else [])),
|
||||
) or []) + [{"user_id": user_id, "starts_at": starts_at, "ends_at": ends_at}]
|
||||
cap = _capacity([user_id], starts_at, ends_at, prospective)
|
||||
overbooked = any(x["state"] == "red" and x["allocated_minutes"] > x["available_minutes"] for x in cap)
|
||||
status = "overlap_and_overbooked" if overlaps and overbooked else "overlap" if overlaps else "overbooked" if overbooked else "none"
|
||||
return status, {"overlapping_ids": [r["id"] for r in overlaps], "capacity": cap}
|
||||
|
||||
|
||||
def _serialize(row: dict | None):
|
||||
if not row: return None
|
||||
return {k: (v.isoformat() if isinstance(v, (datetime, date, time)) else v) for k, v in row.items()}
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
def metadata(current_user: dict = Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
users = execute_query("SELECT user_id AS id, COALESCE(full_name, username) AS name FROM users WHERE is_active ORDER BY name") or []
|
||||
settings = execute_query_single("SELECT warning_percent, full_percent, default_allocation_minutes FROM planner_settings WHERE id=1")
|
||||
departments = execute_query("SELECT id, name FROM groups ORDER BY name") or []
|
||||
statuses = busy_time_provider.statuses([r["id"] for r in users])
|
||||
editable_user_ids = [r["id"] for r in users if _can_edit(current_user, r["id"])]
|
||||
return {"users": users, "departments": departments, "settings": settings, "integration_statuses": statuses,
|
||||
"current_user_id": current_user["id"], "permissions": list(_permissions(current_user)),
|
||||
"editable_user_ids": editable_user_ids}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(get_current_user)):
|
||||
if not (current_user.get("is_superadmin") or "planner.admin" in _permissions(current_user)):
|
||||
raise HTTPException(403, "Kun administratorer kan ændre kapacitetsgrænser")
|
||||
if payload.warning_percent >= payload.full_percent:
|
||||
raise HTTPException(422, "Gul-grænsen skal ligge under rød-grænsen")
|
||||
return execute_query_single(
|
||||
"""UPDATE planner_settings SET warning_percent=%s,full_percent=%s,default_allocation_minutes=%s,
|
||||
updated_by=%s,updated_at=NOW() WHERE id=1 RETURNING *""",
|
||||
(payload.warning_percent,payload.full_percent,payload.default_allocation_minutes,current_user["id"]),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cases")
|
||||
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
|
||||
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
|
||||
department_id: int | None = None,
|
||||
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
|
||||
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
|
||||
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
sql = """SELECT s.id, s.titel AS title, s.status, s.type, s.priority::text, s.ansvarlig_bruger_id AS responsible_id,
|
||||
(LOWER(COALESCE(s.status,'')) = ANY(%s)) AS is_closed,
|
||||
COALESCE(u.full_name,u.username) AS responsible_name, c.name AS customer_name,
|
||||
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
|
||||
COALESCE(used.used_minutes, 0) AS used_minutes,
|
||||
used.latest_registration_at,
|
||||
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
|
||||
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
|
||||
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
|
||||
LEFT JOIN groups department ON department.id=s.assigned_group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(COALESCE(t.faktisk_tid_min,
|
||||
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))),0)::int AS used_minutes,
|
||||
MAX(COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at)) AS latest_registration_at
|
||||
FROM tmodule_times t WHERE t.sag_id=s.id
|
||||
) used ON TRUE
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)"""
|
||||
params: list = [list(CLOSED_STATUSES), list(CLOSED_STATUSES)]
|
||||
if q:
|
||||
sql += " AND (s.id::text ILIKE %s OR s.titel ILIKE %s OR c.name ILIKE %s OR COALESCE(u.full_name,u.username,'') ILIKE %s)"
|
||||
params += [f"%{q}%"] * 4
|
||||
if status: sql += " AND s.status = ANY(%s)"; params.append(status)
|
||||
if priority: sql += " AND s.priority::text = ANY(%s)"; params.append(priority)
|
||||
if case_type: sql += " AND s.type = ANY(%s)"; params.append(case_type)
|
||||
if responsible_id: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(responsible_id)
|
||||
if department_id: sql += " AND s.assigned_group_id=%s"; params.append(department_id)
|
||||
if mine: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(current_user["id"])
|
||||
if urgent: sql += " AND s.priority::text IN ('urgent','high')"
|
||||
sql += " GROUP BY s.id,u.full_name,u.username,c.name,department.name,used.used_minutes,used.latest_registration_at"
|
||||
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
|
||||
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
|
||||
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
|
||||
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
|
||||
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
|
||||
"updated": "s.updated_at DESC"}[sort]
|
||||
rows = execute_query(sql + f" ORDER BY {order} LIMIT %s", tuple(params + [limit])) or []
|
||||
for row in rows: row["remaining_minutes"] = max(0, row["estimated_minutes"] - row["planned_minutes"])
|
||||
return {"items": rows}
|
||||
|
||||
|
||||
@router.get("/cases/{sag_id}/time-entries")
|
||||
def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Show actual registered time separately from planned allocations."""
|
||||
_require_view(current_user)
|
||||
rows = execute_query(
|
||||
"""SELECT t.id, t.worked_date, t.start_tid, t.slut_tid,
|
||||
COALESCE(t.faktisk_tid_min, ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
|
||||
t.description, t.entry_status, t.status,
|
||||
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS employee_name
|
||||
FROM tmodule_times t LEFT JOIN users u ON u.user_id=t.medarbejder_id
|
||||
WHERE t.sag_id=%s ORDER BY COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at) DESC, t.id DESC""",
|
||||
(sag_id,),
|
||||
) or []
|
||||
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
|
||||
|
||||
|
||||
@router.patch("/cases/{sag_id}/estimate")
|
||||
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
|
||||
permissions = _permissions(current_user)
|
||||
allowed = {"planner.edit_own", "planner.edit_team", "planner.admin", "cases.edit"}
|
||||
if not (current_user.get("is_superadmin") or permissions.intersection(allowed)):
|
||||
raise HTTPException(403, "Du må ikke ændre forventet tid")
|
||||
old = execute_query_single("SELECT id,titel,estimated_minutes FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
|
||||
if not old:
|
||||
raise HTTPException(404, "Sagen findes ikke")
|
||||
row = execute_query_single(
|
||||
"UPDATE sag_sager SET estimated_minutes=%s,updated_at=NOW() WHERE id=%s RETURNING id,estimated_minutes",
|
||||
(payload.estimated_minutes, sag_id),
|
||||
)
|
||||
execute_query(
|
||||
"""INSERT INTO audit_log(user_id,username,action,resource_type,resource_id,resource_name,before_value,after_value)
|
||||
VALUES(%s,%s,'planner.estimate.update','case',%s,%s,%s,%s)""",
|
||||
(current_user["id"], current_user.get("username"), sag_id, old.get("titel"),
|
||||
str(old.get("estimated_minutes")), str(payload.estimated_minutes)),
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/period")
|
||||
def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(default=[]), current_user: dict = Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
if ends_at <= starts_at or ends_at - starts_at > timedelta(days=370): raise HTTPException(400, "Ugyldig periode")
|
||||
ids = user_id or [current_user["id"]]
|
||||
allocations = execute_query(
|
||||
"""SELECT a.*, s.titel AS case_title, s.priority::text AS priority, c.name AS customer_name,
|
||||
COALESCE(u.full_name,u.username) AS user_name
|
||||
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id JOIN customers c ON c.id=s.customer_id
|
||||
JOIN users u ON u.user_id=a.user_id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s)
|
||||
AND a.starts_at<%s AND a.ends_at>%s ORDER BY a.starts_at""", (ids, ends_at, starts_at)) or []
|
||||
time_entries = execute_query(
|
||||
"""SELECT t.id, t.sag_id, t.medarbejder_id AS user_id,
|
||||
(t.start_tid IS NOT NULL) AS has_time,
|
||||
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS starts_at,
|
||||
COALESCE(t.slut_tid,
|
||||
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)
|
||||
+ make_interval(mins => GREATEST(COALESCE(t.faktisk_tid_min,
|
||||
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int,15))) AS ends_at,
|
||||
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
|
||||
t.description, s.titel AS case_title, c.name AS customer_name,
|
||||
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS user_name
|
||||
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
|
||||
JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=t.medarbejder_id
|
||||
WHERE t.medarbejder_id=ANY(%s)
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
|
||||
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
|
||||
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)
|
||||
ORDER BY starts_at""",
|
||||
(ids, starts_at, ends_at, list(CLOSED_STATUSES)),
|
||||
) or []
|
||||
return {"allocations": allocations, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
|
||||
"time_entries": time_entries,
|
||||
"external_busy": busy_time_provider.get_busy(ids, starts_at, ends_at),
|
||||
"integration_statuses": busy_time_provider.statuses(ids)}
|
||||
|
||||
|
||||
@router.post("/allocations", status_code=201)
|
||||
def create_allocation(payload: AllocationInput, current_user: dict = Depends(get_current_user)):
|
||||
if not _can_edit(current_user, payload.user_id): raise HTTPException(403, "Du må ikke redigere denne medarbejders plan")
|
||||
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s", (payload.sag_id,))
|
||||
if not case or case["deleted_at"] or str(case["status"]).lower() in CLOSED_STATUSES: raise HTTPException(409, "Lukkede eller arkiverede sager skal genåbnes før planlægning")
|
||||
conflict, details = _conflicts(payload.user_id, payload.starts_at, payload.ends_at)
|
||||
if conflict != "none" and not payload.confirm_conflicts: raise HTTPException(409, detail={"message": "Tiden overlapper eller overbooker", "conflict_status": conflict, **details})
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""INSERT INTO planner_allocations(sag_id,user_id,starts_at,ends_at,note,source,conflict_status,locked,created_by,updated_by)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
|
||||
(payload.sag_id,payload.user_id,payload.starts_at,payload.ends_at,payload.note,payload.source,conflict,payload.locked,current_user["id"],current_user["id"]))
|
||||
row=cur.fetchone(); cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,after_value) VALUES(%s,%s,'created',%s,%s)", (row["id"],row["sag_id"],current_user["id"],Json(_serialize(row))))
|
||||
conn.commit(); return row
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.patch("/allocations/{allocation_id}")
|
||||
def update_allocation(allocation_id: int, payload: AllocationUpdate, current_user: dict = Depends(get_current_user)):
|
||||
conn=get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
|
||||
if not old: raise HTTPException(404,"Allokeringen findes ikke")
|
||||
target=payload.user_id or old["user_id"]
|
||||
if not _can_edit(current_user,target): raise HTTPException(403,"Du må ikke redigere denne medarbejders plan")
|
||||
if old["version"] != payload.version: raise HTTPException(409,detail={"message":"Allokeringen er ændret af en anden bruger","current":_serialize(old)})
|
||||
moving = bool({"user_id", "starts_at", "ends_at"}.intersection(payload.model_fields_set))
|
||||
if old.get("locked") and moving and payload.locked is not False:
|
||||
raise HTTPException(409, "Tidspunktet er låst. Fjern låsen før blokken flyttes eller ændres")
|
||||
start=payload.starts_at or old["starts_at"]; end=payload.ends_at or old["ends_at"]
|
||||
if end<=start: raise HTTPException(422,"Sluttid skal ligge efter starttid")
|
||||
conflict,details=_conflicts(target,start,end,allocation_id)
|
||||
if conflict!="none" and not payload.confirm_conflicts: raise HTTPException(409,detail={"message":"Tiden overlapper eller overbooker","conflict_status":conflict,**details})
|
||||
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,conflict_status=%s,locked=%s,
|
||||
version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s AND version=%s RETURNING *""",
|
||||
(target,start,end,payload.note if "note" in payload.model_fields_set else old["note"],conflict,payload.locked if payload.locked is not None else old.get("locked",False),current_user["id"],allocation_id,payload.version))
|
||||
row=cur.fetchone()
|
||||
if not row: raise HTTPException(409,"Allokeringen blev ændret samtidigt")
|
||||
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'updated',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
|
||||
conn.commit(); return row
|
||||
except HTTPException: conn.rollback(); raise
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.delete("/allocations/{allocation_id}")
|
||||
def delete_allocation(allocation_id:int, version:int, current_user:dict=Depends(get_current_user)):
|
||||
conn=get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
|
||||
if not old: raise HTTPException(404,"Allokeringen findes ikke")
|
||||
if not _can_edit(current_user,old["user_id"]): raise HTTPException(403,"Du må ikke slette denne allokering")
|
||||
if old["version"]!=version: raise HTTPException(409,"Allokeringen er ændret af en anden bruger")
|
||||
if old.get("locked"): raise HTTPException(409,"Tidspunktet er låst. Fjern låsen før blokken slettes")
|
||||
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s AND version=%s RETURNING *",(current_user["id"],allocation_id,version)); row=cur.fetchone()
|
||||
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'deleted',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
|
||||
conn.commit(); return {"deleted":True,"allocation":row}
|
||||
except HTTPException: conn.rollback(); raise
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.post("/allocations/{allocation_id}/undo")
|
||||
def undo_allocation(allocation_id: int, version: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Undo the latest persisted allocation mutation, guarded by its current version."""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM planner_allocations WHERE id=%s FOR UPDATE", (allocation_id,))
|
||||
current = cur.fetchone()
|
||||
if not current: raise HTTPException(404, "Allokeringen findes ikke")
|
||||
if not _can_edit(current_user, current["user_id"]): raise HTTPException(403, "Du må ikke fortryde denne ændring")
|
||||
if current["version"] != version: raise HTTPException(409, "Kan ikke fortryde: allokeringen er ændret siden")
|
||||
cur.execute("SELECT * FROM planner_allocation_history WHERE allocation_id=%s ORDER BY id DESC LIMIT 1", (allocation_id,))
|
||||
history = cur.fetchone()
|
||||
if not history: raise HTTPException(409, "Der er ingen ændring at fortryde")
|
||||
before = history.get("before_value")
|
||||
if history["action"] == "created":
|
||||
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s RETURNING *", (current_user["id"],allocation_id))
|
||||
elif before:
|
||||
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,
|
||||
conflict_status=%s,locked=%s,deleted_at=%s,version=version+1,updated_by=%s,updated_at=NOW()
|
||||
WHERE id=%s RETURNING *""",
|
||||
(before["user_id"],before["starts_at"],before["ends_at"],before.get("note"),before.get("conflict_status","none"),before.get("locked",False),before.get("deleted_at"),current_user["id"],allocation_id))
|
||||
else: raise HTTPException(409, "Ændringen kan ikke fortrydes")
|
||||
row=cur.fetchone()
|
||||
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'undo',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(current)),Json(_serialize(row))))
|
||||
conn.commit(); return row
|
||||
except HTTPException: conn.rollback(); raise
|
||||
except Exception: conn.rollback(); raise
|
||||
finally: release_db_connection(conn)
|
||||
|
||||
|
||||
@router.post("/allocations/{allocation_id}/copy", status_code=201)
|
||||
def copy_allocation(allocation_id:int, starts_at:datetime,user_id:int|None=None,confirm_conflicts:bool=False,current_user:dict=Depends(get_current_user)):
|
||||
old=execute_query_single("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL",(allocation_id,))
|
||||
if not old: raise HTTPException(404,"Allokeringen findes ikke")
|
||||
duration=old["ends_at"]-old["starts_at"]
|
||||
return create_allocation(AllocationInput(sag_id=old["sag_id"],user_id=user_id or old["user_id"],starts_at=starts_at,ends_at=starts_at+duration,note=old["note"],source="copy",confirm_conflicts=confirm_conflicts),current_user)
|
||||
|
||||
|
||||
@router.get("/cases/{sag_id}/history")
|
||||
def case_history(sag_id:int,current_user:dict=Depends(get_current_user)):
|
||||
_require_view(current_user)
|
||||
return {"items":execute_query("SELECT * FROM planner_allocation_history WHERE sag_id=%s ORDER BY created_at DESC",(sag_id,)) or []}
|
||||
1
app/modules/planner/frontend/__init__.py
Normal file
1
app/modules/planner/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Planner frontend package."""
|
||||
11
app/modules/planner/frontend/views.py
Normal file
11
app/modules/planner/frontend/views.py
Normal file
@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from app.core.auth_dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
@router.get("/planner", response_class=HTMLResponse)
|
||||
async def planner(request: Request, current_user: dict = Depends(get_current_user)):
|
||||
return templates.TemplateResponse("modules/planner/templates/index.html", {"request": request, "current_user": current_user})
|
||||
26
app/modules/planner/templates/index.html
Normal file
26
app/modules/planner/templates/index.html
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||
|
||||
@ -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('"', '"') }}"
|
||||
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) {
|
||||
container.innerHTML = '<div class="text-muted text-center p-3">Ingen resultater fundet</div>';
|
||||
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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_permission
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.services.sms_service import SmsService
|
||||
from app.services.system_event_logger import log_system_event
|
||||
|
||||
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
|
||||
from .service import TelefoniService
|
||||
@ -380,6 +381,13 @@ async def yealink_established(
|
||||
raw_payload=json.dumps(payload),
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
log_system_event(
|
||||
source="voip", event_type="voip.established", status="success",
|
||||
summary=f"{direction} opkald etableret: {ekstern_value or 'ukendt nummer'}",
|
||||
correlation_id=resolved_callid, user_id=primary_user_id,
|
||||
details={"call_row_id": row.get("id"), "extension": local_extension,
|
||||
"external_number": ekstern_value, "client_ip": payload["client_ip"], "payload": payload},
|
||||
)
|
||||
|
||||
# Send websocket notification to ALL users with this extension
|
||||
if user_ids:
|
||||
@ -440,10 +448,19 @@ async def yealink_terminated(
|
||||
if duration_value is None:
|
||||
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
|
||||
|
||||
existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,))
|
||||
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
|
||||
if not updated:
|
||||
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
|
||||
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None
|
||||
log_system_event(
|
||||
source="voip", event_type="voip.terminated" if existed else "voip.orphan_hangup",
|
||||
status="success" if existed else "warning",
|
||||
summary="Opkald afsluttet" if existed else "Hangup modtaget uden etablerings-event",
|
||||
correlation_id=resolved_callid, user_id=(existed or {}).get("bruger_id"),
|
||||
details={"duration_raw": duration_raw, "duration_sec": duration_value, "time_entry_id": time_entry_id,
|
||||
"client_ip": _get_client_ip(request)},
|
||||
)
|
||||
return {"status": "ok", "time_entry_id": time_entry_id}
|
||||
|
||||
|
||||
@ -843,6 +860,7 @@ async def list_calls(
|
||||
limit: int = Query(200, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
TelefoniService.reconcile_stale_calls()
|
||||
where = []
|
||||
params = []
|
||||
|
||||
|
||||
@ -9,6 +9,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelefoniService:
|
||||
@staticmethod
|
||||
def reconcile_stale_calls(max_age_hours: int = 12) -> int:
|
||||
"""Close the lifecycle state for calls whose phone never sent hangup."""
|
||||
rows = execute_query(
|
||||
"""WITH stale AS (
|
||||
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup',last_event_at=NOW()
|
||||
WHERE ended_at IS NULL AND lifecycle_status='active'
|
||||
AND started_at < NOW()-(%s * INTERVAL '1 hour')
|
||||
RETURNING id,callid,bruger_id,intern_extension,ekstern_nummer,started_at
|
||||
), logged AS (
|
||||
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
|
||||
jsonb_build_object('call_row_id',id,'extension',intern_extension,'external_number',ekstern_nummer),NOW()
|
||||
FROM stale RETURNING id
|
||||
) SELECT COUNT(*)::int AS count FROM stale""", (max_age_hours,),
|
||||
) or []
|
||||
return int(rows[0].get("count") or 0) if rows else 0
|
||||
|
||||
@staticmethod
|
||||
def find_user_by_extension(extension: Optional[str]) -> list[int]:
|
||||
"""Find all users with the given extension - returns list of user_ids."""
|
||||
@ -183,6 +201,7 @@ class TelefoniService:
|
||||
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
|
||||
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
|
||||
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
|
||||
, lifecycle_status = 'active', last_event_at = NOW()
|
||||
RETURNING *
|
||||
"""
|
||||
rows = execute_query(
|
||||
@ -208,9 +227,9 @@ class TelefoniService:
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO telefoni_opkald
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload)
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at)
|
||||
VALUES
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW())
|
||||
ON CONFLICT (callid)
|
||||
DO UPDATE SET
|
||||
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
|
||||
@ -222,7 +241,7 @@ class TelefoniService:
|
||||
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(callid, duration_sec),
|
||||
|
||||
58
app/products/backend/economic_access.py
Normal file
58
app/products/backend/economic_access.py
Normal 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
|
||||
397
app/products/backend/economic_catalog.py
Normal file
397
app/products/backend/economic_catalog.py
Normal 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 1–25 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()}
|
||||
255
app/products/backend/economic_documents.py
Normal file
255
app/products/backend/economic_documents.py
Normal 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
|
||||
77
app/products/backend/economic_pricing.py
Normal file
77
app/products/backend/economic_pricing.py
Normal 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}
|
||||
579
app/products/backend/economic_routes.py
Normal file
579
app/products/backend/economic_routes.py
Normal 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'])
|
||||
@ -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 1–255 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")
|
||||
|
||||
@ -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 %}
|
||||
|
||||
41
app/products/frontend/economic.html
Normal file
41
app/products/frontend/economic.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -287,10 +287,9 @@ class EconomicService:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
|
||||
logger.warning(f"⚠️ e-conomic update blocked by safety flags (READ_ONLY={settings.ECONOMIC_READ_ONLY}, DRY_RUN={settings.ECONOMIC_DRY_RUN})")
|
||||
logger.info(f"Would update customer {customer_number} with: {update_data}")
|
||||
return False
|
||||
# Customer creation is allowed, updates are deliberately not.
|
||||
logger.warning('e-conomic customer update blocked by the Hub write policy')
|
||||
return False
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@ -379,8 +378,8 @@ class EconomicService:
|
||||
Returns:
|
||||
Created supplier data with supplierNumber or None if failed
|
||||
"""
|
||||
if not self._check_write_permission("create_supplier"):
|
||||
return None
|
||||
logger.warning('e-conomic supplier creation blocked by the Hub write policy')
|
||||
return None
|
||||
|
||||
try:
|
||||
# Build supplier payload
|
||||
@ -824,8 +823,8 @@ class EconomicService:
|
||||
Dict with voucher details or error info
|
||||
"""
|
||||
# 🚨 SAFETY CHECK
|
||||
if not self._check_write_permission("create_journal_supplier_invoice"):
|
||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
||||
logger.warning('e-conomic voucher posting blocked by the Hub write policy')
|
||||
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
|
||||
|
||||
try:
|
||||
# Extract year from invoice date for accounting year
|
||||
@ -1052,8 +1051,8 @@ class EconomicService:
|
||||
Dict with success status
|
||||
"""
|
||||
# 🚨 SAFETY CHECK
|
||||
if not self._check_write_permission("upload_voucher_attachment"):
|
||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
||||
logger.warning('e-conomic voucher attachment blocked by the Hub write policy')
|
||||
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
|
||||
|
||||
try:
|
||||
# Read PDF file
|
||||
|
||||
@ -34,6 +34,7 @@ from aiohttp import ClientSession, BasicAuth
|
||||
import msal
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.system_event_logger import log_system_event
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -976,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"
|
||||
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"}
|
||||
matches = (await response.json()).get("value", [])
|
||||
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 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"}
|
||||
|
||||
@ -1765,6 +1772,11 @@ class EmailService:
|
||||
)
|
||||
if graph_ok:
|
||||
logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject)
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt",
|
||||
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
|
||||
)
|
||||
return True, graph_message
|
||||
graph_failure_message = graph_message
|
||||
logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message)
|
||||
@ -1825,11 +1837,21 @@ class EmailService:
|
||||
)
|
||||
|
||||
logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}")
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt",
|
||||
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
|
||||
)
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ SMTP send error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_system_event(
|
||||
source="email", event_type="email.send_failed", status="error",
|
||||
summary=subject or "E-mail kunne ikke sendes",
|
||||
details={"provider": "smtp", "to": to_addresses, "error": str(e)},
|
||||
)
|
||||
if graph_failure_message:
|
||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}"
|
||||
return False, error_msg
|
||||
@ -1891,6 +1913,11 @@ class EmailService:
|
||||
subject,
|
||||
provider_thread_key,
|
||||
)
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
|
||||
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
|
||||
)
|
||||
return True, graph_message, generated_message_id, provider_thread_key
|
||||
graph_failure_message = graph_message
|
||||
logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message)
|
||||
@ -1970,11 +1997,21 @@ class EmailService:
|
||||
len(to_addresses),
|
||||
subject,
|
||||
)
|
||||
log_system_event(
|
||||
source="email", event_type="email.sent", status="success",
|
||||
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
|
||||
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
|
||||
)
|
||||
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ SMTP send error (attachments): {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_system_event(
|
||||
source="email", event_type="email.send_failed", status="error",
|
||||
summary=subject or "E-mail kunne ikke sendes", correlation_id=generated_message_id,
|
||||
details={"provider": "smtp", "to": to_addresses, "attachments": len(attachments or []), "error": str(e)},
|
||||
)
|
||||
if graph_failure_message:
|
||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key
|
||||
return False, error_msg, generated_message_id, provider_thread_key
|
||||
|
||||
@ -1859,6 +1859,29 @@ class EmailWorkflowService:
|
||||
attachments = []
|
||||
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}")
|
||||
@ -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 {
|
||||
|
||||
@ -413,6 +413,7 @@ REGLER FOR title:
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"top_p": 0.9,
|
||||
@ -458,6 +459,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!
|
||||
@ -526,6 +539,39 @@ REGLER FOR title:
|
||||
"error": error_msg,
|
||||
"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"""
|
||||
|
||||
22
app/services/system_event_logger.py
Normal file
22
app/services/system_event_logger.py
Normal file
@ -0,0 +1,22 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core.database import execute_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_system_event(*, source: str, event_type: str, summary: str, status: str = "info",
|
||||
correlation_id: str | None = None, user_id: int | None = None,
|
||||
details: dict[str, Any] | None = None, occurred_at: datetime | None = None) -> None:
|
||||
"""Best-effort operational event logging; logging must never break the source flow."""
|
||||
try:
|
||||
execute_query(
|
||||
"""INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
VALUES(%s,%s,%s,%s,%s,%s,%s::jsonb,COALESCE(%s,NOW()))""",
|
||||
(source,event_type,status,summary,correlation_id,user_id,json.dumps(details or {},default=str),occurred_at),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist system event %s: %s", event_type, exc)
|
||||
@ -2,13 +2,13 @@
|
||||
Settings and User Management API Router
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.core.config import settings
|
||||
from app.core.auth_dependencies import require_superadmin
|
||||
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
||||
from app.core.auth_service import AuthService
|
||||
import argparse
|
||||
import asyncio
|
||||
@ -1288,3 +1288,76 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||
err = str(e) or e.__class__.__name__
|
||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
||||
|
||||
|
||||
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
|
||||
|
||||
|
||||
def _event_sort_value(event: Dict) -> float:
|
||||
"""Return one comparable timestamp for naive and timezone-aware DB values."""
|
||||
value = event.get("occurred_at")
|
||||
if not value:
|
||||
return 0.0
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.timestamp()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get("/settings/system-events/list", dependencies=[Depends(system_event_log_access)], tags=["Settings"])
|
||||
async def list_system_events(
|
||||
source: Optional[str] = Query(None), event_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None), q: Optional[str] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
|
||||
user_id: Optional[int] = Query(None), limit: int = Query(250, ge=1, le=2000),
|
||||
):
|
||||
"""Unified operational log over persisted system, VoIP and email events."""
|
||||
clauses, params = [], []
|
||||
if source: clauses.append("l.source=%s"); params.append(source)
|
||||
if event_type: clauses.append("l.event_type=%s"); params.append(event_type)
|
||||
if status: clauses.append("l.status=%s"); params.append(status)
|
||||
if user_id: clauses.append("l.user_id=%s"); params.append(user_id)
|
||||
if date_from: clauses.append("l.occurred_at>=%s"); params.append(date_from)
|
||||
if date_to: clauses.append("l.occurred_at<=%s"); params.append(date_to)
|
||||
if q:
|
||||
clauses.append("(l.summary ILIKE %s OR l.correlation_id ILIKE %s OR l.details::text ILIKE %s)")
|
||||
params.extend([f"%{q}%"] * 3)
|
||||
where = "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
events = execute_query(
|
||||
f"""SELECT l.id,l.source,l.event_type,l.status,l.summary,l.correlation_id,l.user_id,
|
||||
l.details,l.occurred_at,COALESCE(u.full_name,u.username) AS user_name
|
||||
FROM system_event_log l LEFT JOIN users u ON u.user_id=l.user_id {where}
|
||||
ORDER BY l.occurred_at DESC LIMIT %s""", tuple(params + [limit]),
|
||||
) or []
|
||||
|
||||
# Existing mail records remain searchable even before producers adopt system_event_log.
|
||||
if source in (None, "email"):
|
||||
email_clauses, email_params = [], []
|
||||
if date_from: email_clauses.append("em.received_date>=%s"); email_params.append(date_from)
|
||||
if date_to: email_clauses.append("em.received_date<=%s"); email_params.append(date_to)
|
||||
if q:
|
||||
email_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.recipient_email ILIKE %s)")
|
||||
email_params.extend([f"%{q}%"] * 3)
|
||||
email_where = "WHERE " + " AND ".join(email_clauses) if email_clauses else ""
|
||||
emails = execute_query(
|
||||
f"""SELECT em.id,'email' AS source,
|
||||
CASE WHEN LOWER(COALESCE(em.folder,'')) LIKE '%%sent%%' THEN 'email.sent' ELSE 'email.received' END AS event_type,
|
||||
'success'::text AS status,COALESCE(em.subject,'(intet emne)') AS summary,
|
||||
em.message_id AS correlation_id,NULL::integer AS user_id,
|
||||
jsonb_build_object('from',em.sender_email,'to',em.recipient_email,'folder',em.folder) AS details,
|
||||
em.received_date AS occurred_at,NULL::text AS user_name
|
||||
FROM email_messages em {email_where} ORDER BY em.received_date DESC LIMIT %s""",
|
||||
tuple(email_params + [limit]),
|
||||
) or []
|
||||
if event_type: emails = [x for x in emails if x["event_type"] == event_type]
|
||||
if status: emails = [x for x in emails if x["status"] == status]
|
||||
events.extend(emails)
|
||||
events.sort(key=_event_sort_value, reverse=True)
|
||||
return {"items": events[:limit], "sources": ["voip", "email", "system"], "count": len(events[:limit])}
|
||||
|
||||
@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_any_permission
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
sql_console_access = require_any_permission("users.manage", "system.admin")
|
||||
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
|
||||
|
||||
CREATE_TABLE_RE = re.compile(
|
||||
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
||||
@ -265,6 +266,11 @@ async def settings_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@router.get("/settings/event-log", response_class=HTMLResponse, tags=["Frontend"], dependencies=[Depends(system_event_log_access)])
|
||||
async def system_event_log_page(request: Request):
|
||||
return templates.TemplateResponse("settings/frontend/event_log.html", {"request": request, "title": "Systemlog"})
|
||||
|
||||
|
||||
@router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"])
|
||||
async def migrations_page(request: Request):
|
||||
"""Render database migrations page"""
|
||||
|
||||
20
app/settings/frontend/event_log.html
Normal file
20
app/settings/frontend/event_log.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
{% block title %}Systemlog · BMC Hub{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="mb-1">Systemlog</h2><p class="text-muted mb-0">VoIP, e-mail og øvrige integrationshændelser</p></div><a href="/settings" class="btn btn-outline-secondary"><i class="bi bi-arrow-left"></i> Indstillinger</a></div>
|
||||
<div class="card p-3 mb-3"><div class="row g-2">
|
||||
<div class="col-md-2"><label class="form-label">Kilde</label><select id="eventSource" class="form-select"><option value="">Alle</option><option value="voip">VoIP</option><option value="email">E-mail</option><option value="system">System</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">Hændelse</label><select id="eventType" class="form-select"><option value="">Alle</option><option value="voip.established">VoIP etableret</option><option value="voip.terminated">VoIP hangup</option><option value="voip.orphan_hangup">Hangup uden start</option><option value="voip.stale_call">Mangler hangup</option><option value="email.received">E-mail modtaget</option><option value="email.sent">E-mail sendt</option><option value="email.send_failed">E-mail sendefejl</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">Status</label><select id="eventStatus" class="form-select"><option value="">Alle</option><option value="success">OK</option><option value="warning">Advarsel</option><option value="error">Fejl</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">Fra</label><input id="eventFrom" type="datetime-local" class="form-control"></div><div class="col-md-2"><label class="form-label">Til</label><input id="eventTo" type="datetime-local" class="form-control"></div>
|
||||
<div class="col-md-2"><label class="form-label">Søg</label><input id="eventQuery" class="form-control" placeholder="Nummer, call-id, emne…"></div>
|
||||
</div></div>
|
||||
<div class="card overflow-hidden"><div class="table-responsive"><table class="table table-hover mb-0"><thead><tr><th>Tid</th><th>Kilde</th><th>Hændelse</th><th>Status</th><th>Beskrivelse</th><th>Bruger</th><th>Korrelations-id</th></tr></thead><tbody id="eventRows"><tr><td colspan="7" class="text-center p-4">Henter log…</td></tr></tbody></table></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}<script>
|
||||
const escapeEvent=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function loadSystemEvents(){const p=new URLSearchParams();const values={source:eventSource.value,event_type:eventType.value,status:eventStatus.value,q:eventQuery.value};Object.entries(values).forEach(([k,v])=>v&&p.set(k,v));if(eventFrom.value)p.set('date_from',new Date(eventFrom.value).toISOString());if(eventTo.value)p.set('date_to',new Date(eventTo.value).toISOString());const r=await fetch('/api/v1/settings/system-events/list?'+p,{credentials:'include'}),b=await r.json();if(!r.ok)throw new Error(b.detail||'Kunne ikke hente log');eventRows.innerHTML=b.items.length?b.items.map(x=>`<tr><td class="text-nowrap">${new Date(x.occurred_at).toLocaleString('da-DK')}</td><td><span class="badge text-bg-secondary">${escapeEvent(x.source)}</span></td><td>${escapeEvent(x.event_type)}</td><td><span class="badge ${x.status==='error'?'text-bg-danger':x.status==='warning'?'text-bg-warning':'text-bg-success'}">${escapeEvent(x.status)}</span></td><td>${escapeEvent(x.summary)}</td><td>${escapeEvent(x.user_name||'–')}</td><td><code>${escapeEvent(x.correlation_id||'–')}</code></td></tr>`).join(''):'<tr><td colspan="7" class="text-center p-4 text-muted">Ingen hændelser matcher filtrene</td></tr>'}
|
||||
let eventTimer;document.querySelectorAll('#eventSource,#eventType,#eventStatus,#eventFrom,#eventTo').forEach(x=>x.addEventListener('change',loadSystemEvents));eventQuery.addEventListener('input',()=>{clearTimeout(eventTimer);eventTimer=setTimeout(loadSystemEvents,300)});loadSystemEvents().catch(e=>eventRows.innerHTML=`<tr><td colspan="7" class="text-danger p-4">${escapeEvent(e.message)}</td></tr>`);
|
||||
</script>{% endblock %}
|
||||
@ -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>
|
||||
@ -208,6 +211,9 @@
|
||||
<a class="nav-link" href="#system" data-tab="system">
|
||||
<i class="bi bi-gear me-2"></i>System
|
||||
</a>
|
||||
<a class="nav-link" href="/settings/event-log">
|
||||
<i class="bi bi-journal-text me-2"></i>Systemlog
|
||||
</a>
|
||||
<a class="nav-link" href="/settings/migrations">
|
||||
<i class="bi bi-database me-2"></i>DB Migrationer
|
||||
</a>
|
||||
@ -260,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>
|
||||
@ -6796,6 +6803,13 @@ const MENU_VISIBILITY_GROUPS = [
|
||||
{ key: 'menu-datamigration', label: 'Data migration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Kalender underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-kalender-overview', label: 'Kalenderoversigt' },
|
||||
{ key: 'menu-planner', label: 'Planlægger' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'CRM underpunkter',
|
||||
items: [
|
||||
|
||||
@ -1486,10 +1486,14 @@
|
||||
<i class="bi bi-list-check me-2"></i>Sager
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" data-menu-key="menu-kalender">
|
||||
<a class="nav-link" href="/calendar">
|
||||
<li class="nav-item dropdown" data-menu-key="menu-kalender">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-calendar3 me-2"></i>Kalender
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li data-menu-key="menu-kalender-overview"><a class="dropdown-item py-2" href="/calendar"><i class="bi bi-calendar3 me-2"></i>Kalenderoversigt</a></li>
|
||||
<li data-menu-key="menu-planner"><a class="dropdown-item py-2" href="/planner"><i class="bi bi-calendar2-week me-2"></i>Planlægger</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" data-menu-key="menu-support">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
@ -2002,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>
|
||||
@ -3080,6 +3084,8 @@ if (bmcOriginalFetch) {
|
||||
{ key: 'menu-crm', label: 'CRM' },
|
||||
{ key: 'menu-sager', label: 'Sager' },
|
||||
{ key: 'menu-kalender', label: 'Kalender' },
|
||||
{ key: 'menu-kalender-overview', label: 'Kalender: Kalenderoversigt' },
|
||||
{ key: 'menu-planner', label: 'Planlægger' },
|
||||
{ key: 'menu-support', label: 'Support' },
|
||||
{ key: 'menu-salg', label: 'Salg' },
|
||||
{ key: 'menu-okonomi', label: 'Økonomi' },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -19,6 +19,7 @@ from fastapi import HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_update, execute_query_single
|
||||
from app.core.economic_write_policy import assert_economic_write_allowed
|
||||
from app.timetracking.backend.models import (
|
||||
TModuleEconomicExportRequest,
|
||||
TModuleEconomicExportResult
|
||||
@ -195,6 +196,43 @@ class EconomicExportService:
|
||||
status_code=400,
|
||||
detail="Order has no lines"
|
||||
)
|
||||
|
||||
from app.products.backend.economic_documents import active_connection, export_document
|
||||
connection = active_connection()
|
||||
if connection:
|
||||
from app.core.auth_service import AuthService
|
||||
if not user_id or not AuthService.user_has_permission(user_id, 'economic.documents.export'):
|
||||
raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
|
||||
if self.read_only or self.dry_run or settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
|
||||
return TModuleEconomicExportResult(success=True, dry_run=True, order_id=request.order_id,
|
||||
message='Safety mode: ingen ekstern skrivning', details={})
|
||||
customer = execute_query_single('SELECT hub_customer_id FROM tmodule_customers WHERE id=%s', (order['customer_id'],))
|
||||
if not customer or not customer['hub_customer_id']:
|
||||
raise HTTPException(409, 'Tidskundens Hub-kobling mangler')
|
||||
time_ids = sorted({time_id for line in lines for time_id in (line.get('time_entry_ids') or [])})
|
||||
if time_ids:
|
||||
other = execute_query_single('''SELECT o.id FROM tmodule_orders o JOIN tmodule_order_lines l ON l.order_id=o.id
|
||||
WHERE o.id<>%s AND o.economic_draft_id IS NOT NULL AND l.time_entry_ids && %s::integer[] LIMIT 1''', (request.order_id, time_ids))
|
||||
if other:
|
||||
raise HTTPException(409, f"Tiden findes allerede på eksporteret ordre {other['id']}")
|
||||
converted = []
|
||||
for line in lines:
|
||||
number = line.get('product_number') or settings.TIMETRACKING_ECONOMIC_PRODUCT
|
||||
product = execute_query_single('SELECT id,economic_unit_number FROM products WHERE economic_connection_id=%s AND economic_product_number=%s', (connection['id'], str(number)))
|
||||
if not connection.get('time_unit_number') or not product or product['economic_unit_number'] != connection['time_unit_number']:
|
||||
raise HTTPException(409, 'Tidsproduktet skal importeres/kobles og have den aftalte timeenhed før eksport')
|
||||
converted.append({**line, 'product_id': product['id'], 'currency':'DKK'})
|
||||
exported = await export_document(connection, 'order', f'tmodule-order:{request.order_id}',
|
||||
customer['hub_customer_id'], converted, notes=order.get('notes'), user_id=user_id,
|
||||
module='timetracking')
|
||||
number = exported['economic_order_number']
|
||||
execute_update('UPDATE tmodule_orders SET economic_draft_id=%s,economic_order_number=%s,exported_at=now(),exported_by=%s WHERE id=%s', (int(number), str(number), user_id, request.order_id))
|
||||
# An exported draft is not a booked invoice: do not mark time as billed here.
|
||||
audit.log_export_completed(order_id=request.order_id, economic_draft_id=int(number),
|
||||
economic_order_number=str(number), dry_run=False, user_id=user_id)
|
||||
return TModuleEconomicExportResult(success=True, dry_run=False, order_id=request.order_id,
|
||||
economic_draft_id=int(number), economic_order_number=str(number),
|
||||
message='Tidsordre eksporteret som kladde; ikke bogført', details={'export_id':exported['export_id']})
|
||||
|
||||
# Log export start
|
||||
audit.log_export_started(
|
||||
@ -437,6 +475,7 @@ class EconomicExportService:
|
||||
|
||||
# Call e-conomic API
|
||||
async with aiohttp.ClientSession() as session:
|
||||
assert_economic_write_allowed("POST", "/orders/drafts")
|
||||
async with session.post(
|
||||
f"{self.api_url}/orders/drafts",
|
||||
headers=self._get_headers(),
|
||||
|
||||
24
docs/ECONOMIC_PRODUCT_CATALOG.md
Normal file
24
docs/ECONOMIC_PRODUCT_CATALOG.md
Normal 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.
|
||||
29
fremtidige planer/README.md
Normal file
29
fremtidige planer/README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Fremtidige planer
|
||||
|
||||
Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men endnu ikke implementeret.
|
||||
|
||||
## 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
|
||||
|
||||
- Nye fremtidige planer oprettes som selvstændige Markdown-filer i denne mappe.
|
||||
- Planens status skal stå øverst i filen.
|
||||
- En plan skal beskrive formål, afgrænsning, løsningsforslag, test og leveringsrækkefølge.
|
||||
- Når udviklingen starter, opdateres status til `Under udvikling`.
|
||||
- Når løsningen er leveret, flyttes planen til undermappen `arkiv` eller markeres tydeligt som implementeret.
|
||||
|
||||
## Ældre plandokumenter uden for denne mappe
|
||||
|
||||
Følgende ældre planer blev fundet i projektet, men deres tilhørende funktioner ser allerede ud til at være implementeret. De er derfor ikke registreret som aktive fremtidsplaner:
|
||||
|
||||
- `MDfile/NEXTCLOUD_MODULE_PLAN.md`
|
||||
- `MDfile/SALES_AND_AGGREGATION_PLAN.md`
|
||||
- `docs/ORDRE_SYSTEM_IMPLEMENTATION.md`
|
||||
- `plan-websiteContentAdministration.prompt.md`
|
||||
- `static/js/## Plan: Manualmodul MVP i BMC Hub.prompt.md`
|
||||
|
||||
De bør gennemgås og eventuelt arkiveres separat, men ikke blandes sammen med endnu ikke implementerede planer.
|
||||
201
fremtidige planer/kundestemning-ai.md
Normal file
201
fremtidige planer/kundestemning-ai.md
Normal file
@ -0,0 +1,201 @@
|
||||
# Kundestemning på indgående sager og mails
|
||||
|
||||
## Status
|
||||
|
||||
Fremtidig udviklingsplan. Ikke implementeret.
|
||||
|
||||
## Formål
|
||||
|
||||
BMC Hub skal bruge AI til semantisk analyse af kundens stemning i indgående mails og sager. Løsningen skal give medarbejderen et rådgivende signal om stemning, hast, kunderisiko og udvikling gennem en mailtråd.
|
||||
|
||||
Analysen må ikke baseres på simpel matching af bestemte ord, og den må i første version ikke automatisk ændre prioritet, SLA eller ansvarlig medarbejder.
|
||||
|
||||
## Analysemodel
|
||||
|
||||
AI-resultatet gemmes pr. indgående mail i den eksisterende `email_analysis`-tabel med `analysis_type = 'customer_sentiment'`.
|
||||
|
||||
Foreslået JSON-resultat:
|
||||
|
||||
```json
|
||||
{
|
||||
"sentiment": "frustrated",
|
||||
"sentiment_score": -0.65,
|
||||
"urgency": "high",
|
||||
"customer_risk": "medium",
|
||||
"requires_attention": true,
|
||||
"confidence": 0.88,
|
||||
"reason": "Kunden har rykket flere gange og oplever fortsat driftsstop.",
|
||||
"signals": [
|
||||
"gentagen henvendelse",
|
||||
"driftspåvirkning",
|
||||
"manglende løsning"
|
||||
],
|
||||
"text_scope": "latest_customer_message",
|
||||
"model": "qwen3.5:9b",
|
||||
"prompt_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Kontrollerede værdier:
|
||||
|
||||
- `sentiment`: `positive`, `neutral`, `concerned`, `frustrated`, `angry`
|
||||
- `urgency`: `normal`, `high`, `critical`
|
||||
- `customer_risk`: `low`, `medium`, `high`
|
||||
- `sentiment_score`: tal fra `-1.0` til `1.0`
|
||||
|
||||
## Tekstforberedelse
|
||||
|
||||
Før teksten sendes til AI, skal systemet så vidt muligt fjerne:
|
||||
|
||||
- Medarbejdernes tidligere svar
|
||||
- Citerede mails
|
||||
- Standardsignaturer
|
||||
- Fortrolighedstekster og disclaimere
|
||||
- Autosvar
|
||||
- HTML, tracking-links og anden teknisk støj
|
||||
|
||||
AI'en skal som udgangspunkt analysere kundens seneste indgående tekst. Emnefeltet kan medtages som kontekst. Tekstrensningen må gerne være regelbaseret, men selve vurderingen skal foretages semantisk af AI.
|
||||
|
||||
## Promptkrav
|
||||
|
||||
Prompten skal holde følgende dimensioner adskilt:
|
||||
|
||||
1. Kundens følelsesmæssige stemning
|
||||
2. Henvendelsens praktiske hast
|
||||
3. Kommerciel risiko eller risiko for eskalation
|
||||
|
||||
Eksempel: En kunde kan være venlig eller neutral, selvom et teknisk problem er kritisk.
|
||||
|
||||
Modellen skal instrueres i at:
|
||||
|
||||
- Forstå indirekte og høfligt formuleret utilfredshed
|
||||
- Undgå at overfortolke almindelig høflighed
|
||||
- Skelne kundens nye tekst fra citeret historik
|
||||
- Undgå vurderinger eller diagnoser af personen
|
||||
- Give en kort begrundelse, der er understøttet af teksten
|
||||
- Bruge lav `confidence`, når teksten er tvetydig
|
||||
- Returnere ren og validerbar JSON
|
||||
|
||||
## Afvikling
|
||||
|
||||
Første version skal:
|
||||
|
||||
- Køre asynkront ved modtagelse af en ekstern mail
|
||||
- Kun analysere indgående kundekommunikation
|
||||
- Springe spam, nyhedsbreve, fakturaer og automatiske systemmails over
|
||||
- Genbruge den eksisterende mailklassifikation til frasortering
|
||||
- Cache resultatet, så samme mail ikke analyseres flere gange
|
||||
- Understøtte manuel genanalyse
|
||||
- Lade mailimporten fortsætte, hvis AI er utilgængelig
|
||||
|
||||
## Samlet stemning på en sag
|
||||
|
||||
Sagens aktuelle stemning bør bygge på den seneste relevante indgående kundemail og ikke den værste historiske vurdering.
|
||||
|
||||
Sagen kan vise:
|
||||
|
||||
- Aktuel stemning fra seneste indgående mail
|
||||
- Trend baseret på de seneste 2-3 indgående mails
|
||||
- Aktuel kunderisiko ved tilstrækkelig høj sikkerhed
|
||||
- `forværret`, hvis scoren falder mærkbart gennem tråden
|
||||
- `forbedret`, hvis scoren stiger efter dialog med kunden
|
||||
|
||||
## GUI
|
||||
|
||||
### Mailoversigt
|
||||
|
||||
Vis en diskret badge:
|
||||
|
||||
- Positiv
|
||||
- Neutral
|
||||
- Bekymret
|
||||
- Frustreret
|
||||
- Vred/kritisk
|
||||
|
||||
Ved klik eller hover vises hast, kunderisiko, AI-begrundelse, sikkerhed, model og analysetidspunkt.
|
||||
|
||||
### Sagsvisning
|
||||
|
||||
Vis et mindre panel med:
|
||||
|
||||
- Aktuel kundestemning
|
||||
- Trend
|
||||
- Seneste begrundelse
|
||||
- Link til den mail, vurderingen kommer fra
|
||||
|
||||
### Senere dashboardfunktioner
|
||||
|
||||
- Filteret `Kræver opmærksomhed`
|
||||
- Filter efter stemning, hast og kunderisiko
|
||||
- Oversigt over forværret kundestemning
|
||||
- Visning af kritiske henvendelser
|
||||
|
||||
## Manuel korrektion
|
||||
|
||||
Medarbejderen skal kunne:
|
||||
|
||||
- Markere vurderingen som korrekt eller forkert
|
||||
- Vælge en anden stemning
|
||||
- Tilføje en kort kommentar
|
||||
|
||||
Den manuelle vurdering skal gemmes separat fra det oprindelige AI-resultat. Det giver sporbarhed og et evalueringsdatasæt til senere forbedring af prompt og model.
|
||||
|
||||
## Automatik og sikkerhed
|
||||
|
||||
I første version er `requires_attention` kun et visuelt signal.
|
||||
|
||||
Mulige senere funktioner:
|
||||
|
||||
- Intern notifikation ved høj risiko
|
||||
- Fremhævelse på dashboardet
|
||||
- Besked til ansvarlig medarbejder
|
||||
- Eskalation af kritiske beskeder uden for normal arbejdstid
|
||||
|
||||
Systemet må ikke automatisk sende svar til kunden eller ændre sagsprioritet uden menneskelig godkendelse.
|
||||
|
||||
## Test og evaluering
|
||||
|
||||
Opret et anonymiseret testsæt på cirka 50-100 realistiske mails, herunder:
|
||||
|
||||
- Positive og neutrale mails
|
||||
- Direkte og indirekte utilfredshed
|
||||
- Vrede mails
|
||||
- Kritisk drift uden vred tone
|
||||
- Ironi og tvetydige formuleringer
|
||||
- Meget korte beskeder
|
||||
- Lange tråde med citeret historik
|
||||
- Autosvar og systemmails
|
||||
- Danske og engelske henvendelser
|
||||
|
||||
Mål især:
|
||||
|
||||
- Hvor ofte kritiske mails overses
|
||||
- Hvor ofte neutrale mails fejlmarkeres
|
||||
- Om hast og stemning adskilles korrekt
|
||||
- Om begrundelsen kan dokumenteres ud fra teksten
|
||||
- Svartid og stabilitet
|
||||
- Forskelle mellem de installerede modeller
|
||||
|
||||
## Leveringsrækkefølge
|
||||
|
||||
1. AI-analyse og lagring pr. indgående mail
|
||||
2. Admin-testside med råt AI-resultat
|
||||
3. Evaluering mod menneskeligt mærkede testmails
|
||||
4. Badge i mailoversigten
|
||||
5. Detaljepanel på mails og sager
|
||||
6. Manuel korrektion og feedback
|
||||
7. Trendberegning på mailtråde
|
||||
8. Dashboard og filtre
|
||||
9. Eventuelle notifikationer
|
||||
|
||||
## Anbefalet lancering
|
||||
|
||||
Start med en stille pilot, hvor nye mails analyseres og gemmes, men resultaterne kun vises på en admin-testside. Aktivér først badges og alarmer bredt, når AI-vurderingerne er sammenlignet med menneskelige vurderinger.
|
||||
|
||||
## Beslutninger før implementering
|
||||
|
||||
- Vælg den endelige AI-model ud fra modeltesten
|
||||
- Fastlæg grænser for `requires_attention`
|
||||
- Beslut hvem der kan se og korrigere vurderinger
|
||||
- Beslut hvor længe analyser og manuelle rettelser skal gemmes
|
||||
- Aftal om engelsk og andre sprog skal understøttes fra første version
|
||||
173
fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md
Normal file
173
fremtidige planer/sikker-fakturaudtraek-hybrid-ai.md
Normal 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.
|
||||
210
fremtidige planer/vareintegration-economic.md
Normal file
210
fremtidige planer/vareintegration-economic.md
Normal 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.
|
||||
21
main.py
21
main.py
@ -82,6 +82,7 @@ from app.fixed_price.frontend import views as fixed_price_views
|
||||
from app.subscriptions.backend import router as subscriptions_api
|
||||
from app.subscriptions.frontend import views as subscriptions_views
|
||||
from app.products.backend import router as products_api
|
||||
from app.products.backend import economic_routes as economic_catalog_routes
|
||||
from app.products.frontend import views as products_views
|
||||
from app.ticket.backend import router as ticket_api
|
||||
from app.ticket.frontend import views as ticket_views
|
||||
@ -133,6 +134,8 @@ from app.modules.telefoni.backend import router as telefoni_api
|
||||
from app.modules.telefoni.frontend import views as telefoni_views
|
||||
from app.modules.calendar.backend import router as calendar_api
|
||||
from app.modules.calendar.frontend import views as calendar_views
|
||||
from app.modules.planner.backend import router as planner_api
|
||||
from app.modules.planner.frontend import views as planner_views
|
||||
from app.modules.orders.backend import router as orders_api
|
||||
from app.modules.orders.frontend import views as orders_views
|
||||
from app.modules.fedex.backend import router as fedex_api
|
||||
@ -224,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
|
||||
|
||||
@ -321,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()
|
||||
@ -483,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"])
|
||||
@ -513,6 +532,7 @@ app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
|
||||
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
|
||||
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
||||
app.include_router(planner_api.router, prefix="/api/v1", tags=["Planner"])
|
||||
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
|
||||
app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
|
||||
app.include_router(shipmondo_api.router, prefix="/api/v1", tags=["Shipmondo"])
|
||||
@ -560,6 +580,7 @@ app.include_router(locations_views.router, tags=["Frontend"])
|
||||
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||
app.include_router(telefoni_views.router, tags=["Frontend"])
|
||||
app.include_router(calendar_views.router, tags=["Frontend"])
|
||||
app.include_router(planner_views.router, tags=["Frontend"])
|
||||
app.include_router(orders_views.router, tags=["Frontend"])
|
||||
app.include_router(fedex_views.router, tags=["Frontend"])
|
||||
app.include_router(anydesk_views.router, tags=["Frontend"])
|
||||
|
||||
134
migrations/240_planner.sql
Normal file
134
migrations/240_planner.sql
Normal file
@ -0,0 +1,134 @@
|
||||
-- Planner: independent case allocations, capacity sources and permissions.
|
||||
ALTER TABLE sag_sager
|
||||
ADD COLUMN IF NOT EXISTS estimated_minutes INTEGER;
|
||||
ALTER TABLE sag_sager
|
||||
ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'ticket';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_settings (
|
||||
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
warning_percent INTEGER NOT NULL DEFAULT 80 CHECK (warning_percent BETWEEN 1 AND 99),
|
||||
full_percent INTEGER NOT NULL DEFAULT 100 CHECK (full_percent >= 100),
|
||||
default_allocation_minutes INTEGER NOT NULL DEFAULT 60 CHECK (default_allocation_minutes > 0),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by INTEGER REFERENCES users(user_id)
|
||||
);
|
||||
INSERT INTO planner_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_work_schedules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
weekday SMALLINT NOT NULL CHECK (weekday BETWEEN 0 AND 6),
|
||||
start_time TIME NOT NULL DEFAULT '08:00',
|
||||
end_time TIME NOT NULL DEFAULT '16:00',
|
||||
break_minutes INTEGER NOT NULL DEFAULT 30 CHECK (break_minutes >= 0),
|
||||
valid_from DATE,
|
||||
valid_to DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CHECK (end_time > start_time)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_work_user ON planner_work_schedules(user_id, weekday);
|
||||
INSERT INTO planner_work_schedules (user_id, weekday, start_time, end_time, break_minutes)
|
||||
SELECT u.user_id, d.weekday, '08:00', '16:00', 30
|
||||
FROM users u CROSS JOIN (VALUES (0),(1),(2),(3),(4)) AS d(weekday)
|
||||
WHERE u.is_active AND NOT EXISTS (
|
||||
SELECT 1 FROM planner_work_schedules w WHERE w.user_id=u.user_id AND w.weekday=d.weekday
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_absences (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
kind VARCHAR(40) NOT NULL DEFAULT 'absence',
|
||||
note TEXT,
|
||||
CHECK (ends_at > starts_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_absence_period ON planner_absences(user_id, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_external_busy (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
provider VARCHAR(40) NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK (ends_at > starts_at),
|
||||
UNIQUE(provider, external_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_external_period ON planner_external_busy(user_id, starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_integrations (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
provider VARCHAR(40),
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'not_connected',
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
CHECK (status IN ('connected', 'degraded', 'not_connected'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_team_scope (
|
||||
editor_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
target_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (editor_user_id, target_user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_allocations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sag_id INTEGER NOT NULL REFERENCES sag_sager(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
starts_at TIMESTAMPTZ NOT NULL,
|
||||
ends_at TIMESTAMPTZ NOT NULL,
|
||||
note TEXT,
|
||||
source VARCHAR(40) NOT NULL DEFAULT 'manual',
|
||||
conflict_status VARCHAR(30) NOT NULL DEFAULT 'none',
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_by INTEGER NOT NULL REFERENCES users(user_id),
|
||||
updated_by INTEGER NOT NULL REFERENCES users(user_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CHECK (ends_at > starts_at),
|
||||
CHECK (conflict_status IN ('none', 'overlap', 'overbooked', 'overlap_and_overbooked'))
|
||||
);
|
||||
ALTER TABLE planner_allocations ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_alloc_period ON planner_allocations(user_id, starts_at, ends_at) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_alloc_case ON planner_allocations(sag_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_allocation_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
allocation_id BIGINT NOT NULL REFERENCES planner_allocations(id),
|
||||
sag_id INTEGER NOT NULL REFERENCES sag_sager(id),
|
||||
action VARCHAR(20) NOT NULL,
|
||||
changed_by INTEGER NOT NULL REFERENCES users(user_id),
|
||||
before_value JSONB,
|
||||
after_value JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_planner_history_case ON planner_allocation_history(sag_id, created_at DESC);
|
||||
|
||||
INSERT INTO permissions (code, description, category) VALUES
|
||||
('planner.view', 'Se relevante planer', 'planner'),
|
||||
('planner.edit_own', 'Redigér egen plan', 'planner'),
|
||||
('planner.edit_team', 'Redigér tilladte medarbejderes planer', 'planner'),
|
||||
('planner.admin', 'Administrér kapacitet og grænser', 'planner')
|
||||
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.category = 'planner'
|
||||
ON CONFLICT 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 = 'Managers' AND p.code IN ('planner.view', 'planner.edit_own', 'planner.edit_team')
|
||||
ON CONFLICT 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 = 'Technicians' AND p.code IN ('planner.view', 'planner.edit_own')
|
||||
ON CONFLICT 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 = 'Viewers' AND p.code = 'planner.view'
|
||||
ON CONFLICT DO NOTHING;
|
||||
37
migrations/241_system_event_log.sql
Normal file
37
migrations/241_system_event_log.sql
Normal file
@ -0,0 +1,37 @@
|
||||
CREATE TABLE IF NOT EXISTS system_event_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source VARCHAR(40) NOT NULL,
|
||||
event_type VARCHAR(80) NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'info',
|
||||
summary TEXT NOT NULL,
|
||||
correlation_id VARCHAR(255),
|
||||
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_event_log_time ON system_event_log(occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_event_log_source ON system_event_log(source, event_type, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_system_event_log_correlation ON system_event_log(correlation_id);
|
||||
|
||||
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS lifecycle_status VARCHAR(30) NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
ALTER TABLE telefoni_opkald ADD COLUMN IF NOT EXISTS termination_source VARCHAR(40);
|
||||
CREATE INDEX IF NOT EXISTS idx_telefoni_open_calls ON telefoni_opkald(started_at) WHERE ended_at IS NULL;
|
||||
|
||||
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
|
||||
jsonb_build_object('call_id',id,'extension',intern_extension,'external_number',ekstern_nummer),started_at
|
||||
FROM telefoni_opkald
|
||||
WHERE ended_at IS NULL AND started_at < NOW()-INTERVAL '12 hours'
|
||||
AND NOT EXISTS (SELECT 1 FROM system_event_log l WHERE l.event_type='voip.stale_call' AND l.correlation_id=telefoni_opkald.callid);
|
||||
|
||||
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup'
|
||||
WHERE ended_at IS NULL AND started_at < NOW()-INTERVAL '12 hours';
|
||||
|
||||
INSERT INTO permissions(code,description,category) VALUES
|
||||
('system.event_log.view','Se samlet systemhændelseslog','system') 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 IN ('Administrators','Managers') AND p.code='system.event_log.view'
|
||||
ON CONFLICT DO NOTHING;
|
||||
152
migrations/242_economic_product_integration.sql
Normal file
152
migrations/242_economic_product_integration.sql
Normal 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;
|
||||
37
migrations/243_economic_four_eyes_changes.sql
Normal file
37
migrations/243_economic_four_eyes_changes.sql
Normal 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;
|
||||
14
migrations/244_economic_permissions_backfill.sql
Normal file
14
migrations/244_economic_permissions_backfill.sql
Normal 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;
|
||||
32
migrations/245_ordre_export_receipt.sql
Normal file
32
migrations/245_ordre_export_receipt.sql
Normal 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;
|
||||
48
migrations/246_economic_order_updates_and_status.sql
Normal file
48
migrations/246_economic_order_updates_and_status.sql
Normal 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;
|
||||
50
migrations/247_also_order_external_id.sql
Normal file
50
migrations/247_also_order_external_id.sql
Normal 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;
|
||||
26
migrations/248_also_order_period_line_break.sql
Normal file
26
migrations/248_also_order_period_line_break.sql
Normal 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;
|
||||
@ -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');
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
return canvas.toDataURL('image/png');
|
||||
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/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,
|
||||
});
|
||||
} 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');
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
scrollX: -window.scrollX,
|
||||
scrollY: -window.scrollY,
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
@ -328,46 +271,48 @@
|
||||
|
||||
async function captureScreenshotBeforeModal() {
|
||||
try {
|
||||
screenshotDataUrl = await withTimeout(
|
||||
takeScreenshotViaDisplayMedia(),
|
||||
12000,
|
||||
'Skærmvalg timed out'
|
||||
pendingScreenshotPromise = pendingScreenshotPromise || withTimeout(
|
||||
takeScreenshot(), 7000, 'Automatisk screenshot tog for lang tid'
|
||||
);
|
||||
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();
|
||||
screenshotDataUrl = await pendingScreenshotPromise;
|
||||
return { ok: true };
|
||||
} catch (firstError) {
|
||||
console.warn('Bug report screenshot first attempt failed', firstError);
|
||||
|
||||
try {
|
||||
pendingScreenshotPromise = withTimeout(
|
||||
takeScreenshot(),
|
||||
8000,
|
||||
'Automatic screenshot timed out'
|
||||
);
|
||||
screenshotDataUrl = await pendingScreenshotPromise;
|
||||
return { ok: true, recovered: true };
|
||||
} catch (secondError) {
|
||||
console.warn('Bug report screenshot retry failed', secondError);
|
||||
screenshotDataUrl = null;
|
||||
return { ok: false, error: secondError || firstError };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Bug report screenshot failed', error);
|
||||
screenshotDataUrl = null;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
105
static/js/economic-catalog.js
Normal file
105
static/js/economic-catalog.js
Normal file
@ -0,0 +1,105 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
let state = {}, products = [], refs = [], offset = 0, saveForm;
|
||||
const labels = {pending:'Afventer',applying:'Udfører',approved:'Godkendt og udført',rejected:'Afvist',cancelled:'Annulleret',running:'Synkroniserer',retry:'Prøver igen',done:'Gennemført',failed:'Fejl',uncertain:'Uafklaret',synced:'Synkroniseret',local_only:'Kun i Hub',unlinked:'Ikke koblet',conflict:'Konflikt'};
|
||||
async function api(path, method='GET', body) {
|
||||
const res = await fetch('/api/v1' + path, {method,headers:{'Content-Type':'application/json'},body:body === undefined ? undefined : JSON.stringify(body)});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw Error(typeof data.detail === 'string' ? data.detail : `Kaldet fejlede (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
function feedback(message,error=false) { $('ecoFeedback').textContent=message; $('ecoFeedback').className='alert '+(error?'alert-danger':'alert-success'); }
|
||||
const connection = () => Number($('ecoConnection').value);
|
||||
function field(name,label,type='text',value='') { return `<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);
|
||||
})();
|
||||
44
static/js/planner.js
Normal file
44
static/js/planner.js
Normal file
@ -0,0 +1,44 @@
|
||||
(() => {
|
||||
const root=document.querySelector('.planner-page'); if(!root)return;
|
||||
const state={view:'week',scope:'mine',anchor:new Date(),meta:null,data:null,cases:[],quick:'unplanned',selected:null,caseRefresh:0};
|
||||
const $=s=>document.querySelector(s), pad=n=>String(n).padStart(2,'0');
|
||||
const localISO=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const dateKey=d=>`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmtMin=m=>m?`${Math.floor(m/60)}t${m%60?' '+m%60+'m':''}`:'0t';
|
||||
const api=async(url,opt={})=>{const r=await fetch(url,{headers:{'Content-Type':'application/json'},...opt});let b;try{b=await r.json()}catch{b={detail:'Ukendt serverfejl'}}if(!r.ok){const e=new Error(typeof b.detail==='string'?b.detail:b.detail?.message||'Handlingen mislykkedes');e.status=r.status;e.body=b;throw e}return b};
|
||||
function range(){let a=new Date(state.anchor),b;if(state.view==='day'){a.setHours(0,0,0,0);b=new Date(a);b.setDate(b.getDate()+1)}else if(state.view==='week'){a.setHours(0,0,0,0);a.setDate(a.getDate()-((a.getDay()+6)%7));b=new Date(a);b.setDate(b.getDate()+7)}else{a=new Date(a.getFullYear(),a.getMonth(),1);b=new Date(a.getFullYear(),a.getMonth()+1,1)}return[a,b]}
|
||||
function selectedUsers(){return state.scope==='mine'?[Number(root.dataset.currentUser)]:state.meta.users.map(u=>u.id)}
|
||||
async function init(){state.meta=await api('/api/v1/planner/metadata');for(const u of state.meta.users){$('#responsibleFilter').insertAdjacentHTML('beforeend',`<option value="${u.id}">${esc(u.name)}</option>`)}for(const d of state.meta.departments||[]){$('#departmentFilter').insertAdjacentHTML('beforeend',`<option value="${d.id}">${esc(d.name)}</option>`)}bind();await Promise.all([loadCases(),loadPeriod()]);setInterval(()=>{if(!document.hidden)loadPeriod()},30000)}
|
||||
function bind(){let timer;$('#caseSearch').addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(loadCases,250)});['statusFilter','priorityFilter','typeFilter','responsibleFilter','departmentFilter','sortFilter'].forEach(id=>$('#'+id).addEventListener('change',loadCases));document.querySelectorAll('[data-quick]').forEach(b=>b.onclick=()=>{state.quick=state.quick===b.dataset.quick?'':b.dataset.quick;document.querySelectorAll('[data-quick]').forEach(x=>x.classList.toggle('active',x.dataset.quick===state.quick));loadCases()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>{state.view=b.dataset.view;document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});document.querySelectorAll('[data-scope]').forEach(b=>b.onclick=()=>{state.scope=b.dataset.scope;document.querySelectorAll('[data-scope]').forEach(x=>x.classList.toggle('active',x===b));loadPeriod()});$('#prevPeriod').onclick=()=>shift(-1);$('#nextPeriod').onclick=()=>shift(1);$('#todayBtn').onclick=()=>{state.anchor=new Date();loadPeriod()};$('.collapse-cases').onclick=()=>$('#casePanel').classList.toggle('collapsed');$('#saveAllocation').onclick=saveSelected;$('#deleteAllocation').onclick=deleteSelected;$('#copyAllocation').onclick=copySelected}
|
||||
function shift(n){if(state.view==='month')state.anchor.setMonth(state.anchor.getMonth()+n);else state.anchor.setDate(state.anchor.getDate()+n*(state.view==='week'?7:1));loadPeriod()}
|
||||
async function loadCases(){const p=new URLSearchParams({q:$('#caseSearch').value,planning:['unplanned','partial'].includes(state.quick)?state.quick:'all',sort:$('#sortFilter').value});if($('#statusFilter').value)p.append('status',$('#statusFilter').value);if($('#priorityFilter').value)p.append('priority',$('#priorityFilter').value);if($('#typeFilter').value)p.append('case_type',$('#typeFilter').value);if($('#responsibleFilter').value)p.set('responsible_id',$('#responsibleFilter').value);if($('#departmentFilter').value)p.set('department_id',$('#departmentFilter').value);if(state.quick==='mine')p.set('mine','true');if(state.quick==='urgent')p.set('urgent','true');try{state.cases=(await api('/api/v1/planner/cases?'+p)).items;const types=[...new Set(state.cases.map(c=>c.type).filter(Boolean))];const current=$('#typeFilter').value;$('#typeFilter').innerHTML='<option value="">Alle sagstyper</option>'+types.map(x=>`<option ${x===current?'selected':''}>${esc(x)}</option>`).join('');renderCases()}catch(e){$('#caseList').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
||||
function setCompactDragImage(e,el){const ghost=document.createElement('div');ghost.textContent=el.querySelector('strong')?.textContent||'Planlæg sag';ghost.style.cssText='position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003';document.body.appendChild(ghost);e.dataTransfer.setDragImage(ghost,18,16);setTimeout(()=>ghost.remove(),0)}
|
||||
function renderCases(){$('#caseList').innerHTML=state.cases.length?state.cases.map(c=>`<article class="case-card" draggable="true" data-case="${c.id}" data-priority="${esc(c.priority)}"><strong>#${c.id} · ${esc(c.title)}</strong><div class="customer">${esc(c.customer_name)} · ${esc(c.responsible_name||'Ikke tildelt')}</div><div class="case-meta"><span>${esc(c.priority)}</span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case="${c.id}" title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${fmtMin(c.estimated_minutes)}</button></div><div class="case-meta"><span>${fmtMin(c.planned_minutes)} planlagt</span><span>${fmtMin(c.remaining_minutes)} tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case="${c.id}" title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${fmtMin(c.used_minutes)} brugt${c.latest_registration_at?' · senest '+new Date(c.latest_registration_at).toLocaleDateString('da-DK'):''}</button><div class="progress"><div class="progress-bar" style="width:${c.estimated_minutes?Math.min(100,c.planned_minutes*100/c.estimated_minutes):0}%"></div></div></article>`).join(''):'<div class="empty">Ingen sager matcher filtrene</div>';document.querySelectorAll('.case-card').forEach(el=>el.ondragstart=e=>{e.dataTransfer.setData('application/x-case',el.dataset.case);setCompactDragImage(e,el)});document.querySelectorAll('[data-time-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();showTimeEntries(Number(el.dataset.timeCase))});document.querySelectorAll('[data-estimate-case]').forEach(el=>el.onclick=e=>{e.stopPropagation();setEstimate(Number(el.dataset.estimateCase))})}
|
||||
async function setEstimate(caseId){const c=state.cases.find(x=>x.id===caseId),answer=prompt('Forventet tidsforbrug i timer (fx 2,5):',c?.estimated_minutes?String(c.estimated_minutes/60).replace('.',','):'');if(answer===null)return;const hours=Number(answer.replace(',','.'));if(!Number.isFinite(hours)||hours<0){toast('Angiv et gyldigt antal timer');return}try{await api(`/api/v1/planner/cases/${caseId}/estimate`,{method:'PATCH',body:JSON.stringify({estimated_minutes:Math.round(hours*60)})});toast('Forventet tid er gemt');await loadCases()}catch(e){toast(e.message)}}
|
||||
async function showTimeEntries(caseId){const c=state.cases.find(x=>x.id===caseId);$('#timeDialogTitle').textContent=`#${caseId} · ${c?.title||'Sag'}`;$('#timeDialogTotal').textContent='Henter tidsregistreringer…';$('#timeEntryList').innerHTML='';$('#timeDialogCaseLink').href=`/sag/${caseId}/v3`;$('#timeDialog').showModal();try{const data=await api(`/api/v1/planner/cases/${caseId}/time-entries`);$('#timeDialogTotal').textContent=`I alt ${fmtMin(data.total_minutes)} brugt`;$('#timeEntryList').innerHTML=data.items.length?data.items.map(x=>{const when=x.start_tid?new Date(x.start_tid).toLocaleString('da-DK'):(x.worked_date?new Date(x.worked_date+'T12:00').toLocaleDateString('da-DK'):'Ukendt tidspunkt');return `<div class="border rounded p-2"><div class="d-flex justify-content-between"><strong>${esc(x.employee_name)}</strong><span>${fmtMin(x.minutes)}</span></div><small class="text-muted">${esc(when)}${x.slut_tid?' – '+new Date(x.slut_tid).toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}):''}</small>${x.description?`<div class="small mt-1">${esc(x.description)}</div>`:''}</div>`}).join(''):'<div class="text-muted">Ingen tid registreret endnu.</div>'}catch(e){$('#timeDialogTotal').textContent=e.message}}
|
||||
async function loadPeriod(){const [a,b]=range(),p=new URLSearchParams({starts_at:a.toISOString(),ends_at:b.toISOString()});selectedUsers().forEach(id=>p.append('user_id',id));$('#timeline').innerHTML='<div class="empty">Henter plan…</div>';try{state.data=await api('/api/v1/planner/period?'+p);render(a,b)}catch(e){$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`}}
|
||||
function render(a,b){const opts={day:'numeric',month:'short'};$('#periodLabel').textContent=state.view==='month'?a.toLocaleDateString('da-DK',{month:'long',year:'numeric'}):`${a.toLocaleDateString('da-DK',opts)} – ${new Date(b-1).toLocaleDateString('da-DK',{...opts,year:'numeric'})}`;state.view==='month'?renderMonth(a):renderTimeline(a,b)}
|
||||
function cap(uid,key){return state.data.capacity.find(c=>c.user_id===uid&&c.date===key)||{state:'gray',label:'Ingen arbejdstid'}}
|
||||
function canEdit(uid){return (state.meta.editable_user_ids||[]).includes(Number(uid))}
|
||||
function integrationWarning(){return (state.data.integration_statuses||[]).some(x=>x.status==='degraded')?'<div class="alert alert-warning m-2 py-2"><i class="bi bi-cloud-slash me-1"></i>Ekstern travl tid kan mangle. Intern planlægning virker fortsat.</div>':''}
|
||||
function blockStyle(start,end,lane={index:0,count:1}){const first=7*60,last=18*60,total=last-first,s=new Date(start),e=new Date(end),sm=s.getHours()*60+s.getMinutes(),em=e.getHours()*60+e.getMinutes(),top=Math.max(0,(sm-first)/total*660),height=Math.max(24,(Math.min(last,em)-Math.max(first,sm))/total*660),left=lane.index*(100/lane.count),leftPx=26-lane.index*(29/lane.count),width=100/lane.count,widthPx=29/lane.count;return `top:${top}px;height:${height}px;left:calc(${left}% + ${leftPx}px);right:auto;width:calc(${width}% - ${widthPx}px)`}
|
||||
function overlapLayout(planned,registered){const all=[...planned.map(item=>({key:`p:${item.id}`,item})),...registered.map(item=>({key:`r:${item.id}`,item}))].sort((a,b)=>new Date(a.item.starts_at)-new Date(b.item.starts_at));const result=new Map();let group=[],groupEnd=0;const flush=()=>{if(!group.length)return;const laneEnds=[];for(const event of group){const start=+new Date(event.item.starts_at);let lane=laneEnds.findIndex(end=>end<=start);if(lane<0)lane=laneEnds.length;laneEnds[lane]=+new Date(event.item.ends_at);event.lane=lane}for(const event of group)result.set(event.key,{index:event.lane,count:laneEnds.length});group=[];groupEnd=0};for(const event of all){const start=+new Date(event.item.starts_at),end=+new Date(event.item.ends_at);if(group.length&&start>=groupEnd)flush();group.push(event);groupEnd=Math.max(groupEnd,end)}flush();return result}
|
||||
function hourMarks(){return Array.from({length:12},(_,i)=>`<span class="hour-mark" style="top:${i*60}px">${pad(i+7)}:00</span>`).join('')}
|
||||
function registeredHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="registered-time" style="${blockStyle(x.starts_at,x.ends_at,lane)}"><div class="time"><i class="bi bi-stopwatch me-1"></i>${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())} · registreret</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${fmtMin(x.minutes)} brugt</div></div>`}
|
||||
function untimedHtml(items){return items.length?`<div class="untimed-registers">${items.map(x=>`<div class="untimed-register" title="${esc(x.case_title)}"><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · #${x.sag_id} · ${fmtMin(x.minutes)} registreret</div>`).join('')}</div>`:''}
|
||||
function renderTimeline(a,b){const users=state.meta.users.filter(u=>selectedUsers().includes(u.id)),days=[];for(let d=new Date(a);d<b;d.setDate(d.getDate()+1))days.push(new Date(d));const cols=`150px repeat(${days.length},minmax(130px,1fr))`;let html=integrationWarning()+`<div class="week-grid" style="grid-template-columns:${cols}"><div class="grid-cell grid-head employee-head">Medarbejder</div>${days.map(d=>`<div class="grid-cell grid-head">${d.toLocaleDateString('da-DK',{weekday:'short',day:'numeric',month:'short'})}</div>`).join('')}`;for(const u of users){html+=`<div class="grid-cell employee-head">${esc(u.name)}<div class="small text-muted mt-2">Arbejdsdag 07–18</div></div>`;for(const d of days){const key=dateKey(d),c=cap(u.id,key),items=state.data.allocations.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),allRegistered=(state.data.time_entries||[]).filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),registered=allRegistered.filter(x=>x.has_time),untimed=allRegistered.filter(x=>!x.has_time),busy=state.data.external_busy.filter(x=>x.user_id===u.id&&dateKey(new Date(x.starts_at))===key),lanes=overlapLayout(items,registered);html+=`<div class="grid-cell drop-cell" data-user="${u.id}" data-date="${key}">${hourMarks()}<span class="capacity-pill"><i class="dot ${c.state}"></i>${esc(c.label)} · ${esc(c.registered_label||'0 timer registreret')}</span>${busy.map(x=>`<div class="external-busy" style="${blockStyle(x.starts_at,x.ends_at)}">Ekstern travl tid</div>`).join('')}${registered.map(x=>registeredHtml(x,lanes.get(`r:${x.id}`))).join('')}${items.map(x=>allocationHtml(x,lanes.get(`p:${x.id}`))).join('')}${untimedHtml(untimed)}</div>`}}html+='</div>';$('#timeline').innerHTML=html;bindCalendar()}
|
||||
function allocationHtml(x,lane){const s=new Date(x.starts_at),e=new Date(x.ends_at);return `<div class="allocation ${x.conflict_status!=='none'?'conflict':''} ${x.locked?'locked':''}" style="${blockStyle(x.starts_at,x.ends_at,lane)}" draggable="${x.locked?'false':'true'}" data-allocation="${x.id}"><div class="time">${x.locked?'<i class="bi bi-lock-fill me-1"></i>':''}${pad(s.getHours())}:${pad(s.getMinutes())}–${pad(e.getHours())}:${pad(e.getMinutes())}</div><strong>#${x.sag_id} ${esc(x.case_title)}</strong><div>${esc(x.customer_name)}</div></div>`}
|
||||
function renderMonth(a){const start=new Date(a);start.setDate(1-((start.getDay()+6)%7));let html=integrationWarning()+'<div class="month-grid">'+['Man','Tir','Ons','Tor','Fre','Lør','Søn'].map(x=>`<div class="grid-cell grid-head">${x}</div>`).join('');for(let i=0;i<42;i++){const d=new Date(start);d.setDate(d.getDate()+i);const key=dateKey(d),caps=selectedUsers().map(uid=>cap(uid,key)),alloc=state.data.allocations.filter(x=>dateKey(new Date(x.starts_at))===key),planned=caps.reduce((n,c)=>n+c.allocated_minutes,0),registered=caps.reduce((n,c)=>n+(c.registered_minutes||0),0),available=caps.reduce((n,c)=>n+c.available_minutes,0);html+=`<div class="month-day ${d.getMonth()!==a.getMonth()?'other':''}" data-date="${key}"><span class="day-num">${d.getDate()}</span><div class="month-cap">${caps.map(c=>`<span title="${esc(c.label)}"><i class="dot ${c.state}"></i></span>`).join('')} ${fmtMin(planned)} af ${fmtMin(available)} planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${fmtMin(registered)} registreret</span></div>${alloc.slice(0,3).map(x=>`<div class="allocation" data-allocation="${x.id}">#${x.sag_id} ${esc(x.case_title)}</div>`).join('')}</div>`}html+='</div>';$('#timeline').innerHTML=html;document.querySelectorAll('.month-day').forEach(el=>el.onclick=e=>{if(e.target.closest('.allocation'))return;state.anchor=new Date(el.dataset.date+'T12:00');state.view='day';document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view==='day'));loadPeriod()});bindCalendar()}
|
||||
function bindCalendar(){document.querySelectorAll('.drop-cell').forEach(el=>{if(canEdit(el.dataset.user)){el.ondragover=e=>e.preventDefault();el.ondrop=e=>drop(e,el)}});document.querySelectorAll('[data-allocation]').forEach(el=>{const item=state.data.allocations.find(x=>x.id===Number(el.dataset.allocation));el.onclick=()=>openAllocation(Number(el.dataset.allocation));el.draggable=Boolean(item&&canEdit(item.user_id)&&!item.locked);el.ondragstart=e=>{if(item&&canEdit(item.user_id)&&!item.locked)e.dataTransfer.setData('application/x-allocation',el.dataset.allocation)}})}
|
||||
async function drop(e,cell){e.preventDefault();const caseId=e.dataTransfer.getData('application/x-case'),allocId=e.dataTransfer.getData('application/x-allocation');const rect=cell.getBoundingClientRect(),ratio=Math.max(0,Math.min(1,(e.clientY-rect.top)/rect.height)),minutes=Math.round((7*60+ratio*11*60)/15)*15;const start=new Date(cell.dataset.date+'T00:00');start.setMinutes(minutes);if(caseId){const c=state.cases.find(x=>x.id===Number(caseId)),defaultMins=Math.max(15,Number(state.meta.settings.default_allocation_minutes)||60),remainingMins=Math.max(15,Number(c?.remaining_minutes)||defaultMins),mins=Math.min(remainingMins,defaultMins);await mutate('/api/v1/planner/allocations','POST',{sag_id:Number(caseId),user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+mins*60000).toISOString(),source:'manual'})}else if(allocId){const x=state.data.allocations.find(a=>a.id===Number(allocId)),dur=new Date(x.ends_at)-new Date(x.starts_at);await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{user_id:Number(cell.dataset.user),starts_at:start.toISOString(),ends_at:new Date(start.getTime()+dur).toISOString(),version:x.version})}}
|
||||
function openAllocation(id){const x=state.data.allocations.find(a=>a.id===id);if(!x)return;state.selected=x;const editable=canEdit(x.user_id);$('#saveAllocation').classList.toggle('d-none',!editable);$('#deleteAllocation').classList.toggle('d-none',!editable||x.locked);$('#copyAllocation').classList.toggle('d-none',!editable);$('#dialogTitle').textContent=`#${x.sag_id} · ${x.case_title}`;$('#dialogCustomer').textContent=`${x.customer_name} · ${x.user_name}`;$('#dialogStart').value=localISO(new Date(x.starts_at));$('#dialogEnd').value=localISO(new Date(x.ends_at));$('#dialogNote').value=x.note||'';$('#dialogLocked').checked=Boolean(x.locked);$('#dialogStart').disabled=!editable;$('#dialogEnd').disabled=!editable;$('#dialogNote').disabled=!editable;$('#dialogLocked').disabled=!editable;$('#dialogCaseLink').href=`/sag/${x.sag_id}/v3`;$('#dialogWarning').classList.add('d-none');$('#allocationDialog').showModal()}
|
||||
async function saveSelected(){const x=state.selected;await mutate(`/api/v1/planner/allocations/${x.id}`,'PATCH',{starts_at:new Date($('#dialogStart').value).toISOString(),ends_at:new Date($('#dialogEnd').value).toISOString(),note:$('#dialogNote').value,locked:$('#dialogLocked').checked,version:x.version},true)}
|
||||
async function deleteSelected(){const x=state.selected;if(!confirm('Slet denne tidsblok? Sagen bevares.'))return;try{const result=await api(`/api/v1/planner/allocations/${x.id}?version=${x.version}`,{method:'DELETE'});$('#allocationDialog').close();const a=result.allocation;toast('Tidsblokken blev slettet','Fortryd',()=>undoMutation(a.id,a.version));await refresh()}catch(e){toast(e.message)}}
|
||||
async function copySelected(){const x=state.selected,start=new Date(x.starts_at);start.setDate(start.getDate()+1);await mutate(`/api/v1/planner/allocations/${x.id}/copy?starts_at=${encodeURIComponent(start.toISOString())}`,'POST',null,true)}
|
||||
async function mutate(url,method,body,close=false){try{const result=await api(url,{method,body:body?JSON.stringify(body):undefined});if(close)$('#allocationDialog').close();toast('Planen er gemt','Fortryd',result?.id&&result?.version?()=>undoMutation(result.id,result.version):null);await refresh()}catch(e){if(e.status===409&&e.body?.detail?.conflict_status&&confirm(`${e.message}. Vil du gemme alligevel?`)){body.confirm_conflicts=true;return mutate(url,method,body,close)}toast(e.message);await loadPeriod()}}
|
||||
async function undoMutation(id,version){try{await api(`/api/v1/planner/allocations/${id}/undo?version=${version}`,{method:'POST'});toast('Ændringen blev fortrudt');await refresh()}catch(e){toast(e.message);await loadPeriod()}}
|
||||
async function refresh(){await Promise.all([loadCases(),loadPeriod()])}
|
||||
function toast(message,action,fn){const el=document.createElement('div');el.className='planner-toast';el.textContent=message;if(action&&fn){const b=document.createElement('button');b.textContent=action;b.onclick=()=>{fn();el.remove()};el.append(b)}$('#toastStack').append(el);setTimeout(()=>el.remove(),6000)}
|
||||
init().catch(e=>{$('#timeline').innerHTML=`<div class="empty text-danger">${esc(e.message)}</div>`});
|
||||
})();
|
||||
65
tests/browser/economic_catalog.cjs
Normal file
65
tests/browser/economic_catalog.cjs
Normal 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;});
|
||||
395
tests/test_economic_catalog.py
Normal file
395
tests/test_economic_catalog.py
Normal 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
|
||||
106
tests/test_internet_quick_setup.py
Normal file
106
tests/test_internet_quick_setup.py
Normal 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
|
||||
104
tests/test_invoice_hybrid_extraction.py
Normal file
104
tests/test_invoice_hybrid_extraction.py
Normal 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
|
||||
19
tests/test_ollama_amount_normalization.py
Normal file
19
tests/test_ollama_amount_normalization.py
Normal 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
|
||||
108
tests/test_order_economic_safety_config.py
Normal file
108
tests/test_order_economic_safety_config.py
Normal 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")
|
||||
113
tests/test_planner.py
Normal file
113
tests/test_planner.py
Normal file
@ -0,0 +1,113 @@
|
||||
from datetime import datetime, time, timezone
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.modules.planner.backend import router as planner
|
||||
|
||||
|
||||
def test_planner_migration_has_independent_versioned_allocations_and_capacity_sources():
|
||||
sql = Path("migrations/240_planner.sql").read_text(encoding="utf-8")
|
||||
for table in (
|
||||
"planner_allocations", "planner_allocation_history", "planner_work_schedules",
|
||||
"planner_absences", "planner_external_busy", "planner_integrations", "planner_settings",
|
||||
):
|
||||
assert f"CREATE TABLE IF NOT EXISTS {table}" in sql
|
||||
assert "version INTEGER NOT NULL DEFAULT 1" in sql
|
||||
assert "ALTER TABLE sag_sager" in sql
|
||||
assert "estimated_minutes" in sql
|
||||
assert "planner.edit_team" in sql
|
||||
|
||||
|
||||
def test_allocation_input_rejects_zero_or_negative_duration():
|
||||
with pytest.raises(Exception):
|
||||
planner.AllocationInput(
|
||||
sag_id=1, user_id=2,
|
||||
starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 10),
|
||||
)
|
||||
|
||||
|
||||
def test_capacity_uses_work_time_less_break_absence_and_external_busy(monkeypatch):
|
||||
def fake_single(query, _params=None):
|
||||
if "planner_settings" in query:
|
||||
return {"warning_percent": 80, "full_percent": 100}
|
||||
return None
|
||||
|
||||
def fake_query(query, _params=None):
|
||||
if "planner_work_schedules" in query:
|
||||
return [{"user_id": 7, "weekday": 1, "start_time": time(8), "end_time": time(16), "break_minutes": 30}]
|
||||
if "planner_absences" in query:
|
||||
return [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 13, tzinfo=planner.TZ)}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(planner, "execute_query_single", fake_single)
|
||||
monkeypatch.setattr(planner, "execute_query", fake_query)
|
||||
monkeypatch.setattr(planner.busy_time_provider, "get_busy", lambda *_: [{
|
||||
"user_id": 7, "starts_at": datetime(2026, 9, 8, 14, tzinfo=planner.TZ),
|
||||
"ends_at": datetime(2026, 9, 8, 15, tzinfo=planner.TZ), "provider": "outlook",
|
||||
}])
|
||||
allocations = [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 8, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ)}]
|
||||
result = planner._capacity(
|
||||
[7], datetime(2026, 9, 8, tzinfo=planner.TZ),
|
||||
datetime(2026, 9, 9, tzinfo=planner.TZ), allocations,
|
||||
)[0]
|
||||
assert result["available_minutes"] == 330 # 8h - 30m break - 2h unavailable
|
||||
assert result["allocated_minutes"] == 240
|
||||
assert result["state"] == "green"
|
||||
assert result["label"] == "4 af 5.5 timer planlagt"
|
||||
assert result["registered_minutes"] == 0
|
||||
assert result["registered_label"] == "0 timer registreret"
|
||||
|
||||
|
||||
def test_own_and_team_edit_permissions_are_separate(monkeypatch):
|
||||
own = {"id": 4, "permissions": ["planner.edit_own"]}
|
||||
assert planner._can_edit(own, 4)
|
||||
assert not planner._can_edit(own, 5)
|
||||
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"allowed": 1})
|
||||
assert planner._can_edit({"id": 4, "permissions": ["planner.edit_team"]}, 5)
|
||||
|
||||
|
||||
def test_overlapping_allocations_each_consume_capacity():
|
||||
start = datetime(2026, 9, 8, 8, tzinfo=planner.TZ)
|
||||
end = datetime(2026, 9, 8, 12, tzinfo=planner.TZ)
|
||||
assert planner._sum_minutes([(start, end), (start, end)], start, end) == 480
|
||||
|
||||
|
||||
def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
|
||||
html = Path("app/modules/planner/templates/index.html").read_text(encoding="utf-8")
|
||||
js = Path("static/js/planner.js").read_text(encoding="utf-8")
|
||||
for text in ("Min plan", "Team", "Dag", "Uge", "Måned", "Ikke planlagt", "Delvist planlagt", "Mine sager", "Haster"):
|
||||
assert text in html
|
||||
for feature in ("application/x-case", "application/x-allocation", "confirm_conflicts", "undoMutation", "time-entries", "used_minutes", "estimated_minutes", "dialogLocked", "hourMarks", "blockStyle", "overlapLayout"):
|
||||
assert feature in js
|
||||
assert "Fastlås tidspunkt" in html
|
||||
assert "planner.estimate.update" in Path("app/modules/planner/backend/router.py").read_text()
|
||||
assert "registered-time" in html
|
||||
assert "registeredHtml" in js
|
||||
assert "time_entries" in js
|
||||
assert "Ekstern travl tid" in js
|
||||
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
|
||||
|
||||
|
||||
def test_closed_case_is_checked_before_allocation(monkeypatch):
|
||||
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
||||
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
||||
payload = planner.AllocationInput(
|
||||
sag_id=9, user_id=2, starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 11)
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
planner.create_allocation(payload, {"id": 2, "permissions": ["planner.edit_own"]})
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
def test_active_case_list_only_hides_closed_cases_that_already_have_time():
|
||||
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
|
||||
cases_query = source.split('@router.get("/cases")', 1)[1].split('@router.get("/cases/{sag_id}/time-entries")', 1)[0]
|
||||
assert "NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)" in cases_query
|
||||
assert "AS is_closed" in cases_query
|
||||
Loading…
Reference in New Issue
Block a user