Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee274a939a | ||
|
|
b95815de0d | ||
|
|
dbdb1c554a | ||
|
|
85fd99c5d8 | ||
|
|
4971f9721c | ||
|
|
a523bc4ac3 | ||
|
|
2a71e9285a | ||
|
|
b3059dced1 | ||
|
|
87932f8e16 | ||
|
|
3e8e5ba1cf | ||
|
|
631fc6cdca |
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.
|
||||||
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.
|
||||||
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',
|
||||||
|
)
|
||||||
@ -275,6 +275,21 @@ def _extract_period_start(value: Any) -> Optional[str]:
|
|||||||
return _parse_date_candidate(match.group(1))
|
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:
|
def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
|
||||||
product_name = _normalize_match_key(line.get("product_name"))
|
product_name = _normalize_match_key(line.get("product_name"))
|
||||||
if "microsoftorganizationtenant" not in 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"],
|
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
|
||||||
"currency": ["currency", "valuta"],
|
"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"],
|
"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"],
|
"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"],
|
"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"],
|
"source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
|
||||||
@ -2008,6 +2024,10 @@ class AlsoService:
|
|||||||
quantity = Decimal("1")
|
quantity = Decimal("1")
|
||||||
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
|
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))
|
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(
|
draft_lines.append(
|
||||||
{
|
{
|
||||||
@ -2015,7 +2035,7 @@ class AlsoService:
|
|||||||
"source_type": "also_cloud",
|
"source_type": "also_cloud",
|
||||||
"source_id": int(line["id"]),
|
"source_id": int(line["id"]),
|
||||||
"reference_id": int(line["import_job_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),
|
"quantity": float(quantity),
|
||||||
"unit": "stk",
|
"unit": "stk",
|
||||||
"unit_price": float(unit_price),
|
"unit_price": float(unit_price),
|
||||||
@ -2032,6 +2052,7 @@ class AlsoService:
|
|||||||
"also_material_number": line.get("material_number"),
|
"also_material_number": line.get("material_number"),
|
||||||
"also_vendor": line.get("vendor"),
|
"also_vendor": line.get("vendor"),
|
||||||
"also_import_job_id": int(line.get("import_job_id")),
|
"also_import_job_id": int(line.get("import_job_id")),
|
||||||
|
"billing_period": charge_period,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -2067,7 +2088,7 @@ class AlsoService:
|
|||||||
]
|
]
|
||||||
execute_query(
|
execute_query(
|
||||||
"""UPDATE ordre_drafts
|
"""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""",
|
WHERE id = %s""",
|
||||||
(_json_dumps(merged_lines), int(draft["id"])),
|
(_json_dumps(merged_lines), int(draft["id"])),
|
||||||
)
|
)
|
||||||
@ -2090,10 +2111,10 @@ class AlsoService:
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
f"ALSO Cloud {customer_name} - {period_key}",
|
"Abonnementer",
|
||||||
customer_id,
|
customer_id,
|
||||||
_json_dumps(draft_lines),
|
_json_dumps(draft_lines),
|
||||||
"Genereret fra ALSO Cloud Billing approval",
|
None,
|
||||||
1,
|
1,
|
||||||
approved_by_user_id,
|
approved_by_user_id,
|
||||||
_json_dumps({"source": "also_cloud_billing"}),
|
_json_dumps({"source": "also_cloud_billing"}),
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import execute_query, execute_query_single
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -43,6 +44,35 @@ def _economic_error_message(status: int, response_text: str) -> str:
|
|||||||
return f"e-conomic afviste ordren ({status})" + (f": {detail}" if detail else "")
|
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:
|
class OrdreEconomicExportService:
|
||||||
"""e-conomic export service for global ordre page."""
|
"""e-conomic export service for global ordre page."""
|
||||||
|
|
||||||
@ -97,7 +127,21 @@ class OrdreEconomicExportService:
|
|||||||
notes: Optional[str] = None,
|
notes: Optional[str] = None,
|
||||||
layout_number: Optional[int] = None,
|
layout_number: Optional[int] = None,
|
||||||
user_id: 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]:
|
) -> 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(
|
customer = execute_query_single(
|
||||||
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
@ -121,7 +165,9 @@ class OrdreEconomicExportService:
|
|||||||
product_map: Dict[int, str] = {}
|
product_map: Dict[int, str] = {}
|
||||||
if product_ids:
|
if product_ids:
|
||||||
product_rows = execute_query(
|
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,),
|
(product_ids,),
|
||||||
) or []
|
) or []
|
||||||
product_map = {
|
product_map = {
|
||||||
@ -129,8 +175,13 @@ class OrdreEconomicExportService:
|
|||||||
for row in product_rows
|
for row in product_rows
|
||||||
if row.get("sku_internal")
|
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]] = []
|
economic_lines: List[Dict[str, Any]] = []
|
||||||
|
creation_candidates: Dict[str, Dict[str, Any]] = {}
|
||||||
|
created_product_numbers: List[str] = []
|
||||||
for line in selected_lines:
|
for line in selected_lines:
|
||||||
try:
|
try:
|
||||||
quantity = float(line.get("quantity") or 0)
|
quantity = float(line.get("quantity") or 0)
|
||||||
@ -163,6 +214,16 @@ class OrdreEconomicExportService:
|
|||||||
|
|
||||||
if product_number:
|
if product_number:
|
||||||
line_payload["product"] = {"productNumber": str(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:
|
if discount > 0:
|
||||||
line_payload["discountPercentage"] = discount
|
line_payload["discountPercentage"] = discount
|
||||||
@ -213,10 +274,7 @@ class OrdreEconomicExportService:
|
|||||||
if product_response.status == 200:
|
if product_response.status == 200:
|
||||||
valid_product_numbers.add(product_number)
|
valid_product_numbers.add(product_number)
|
||||||
elif product_response.status == 404:
|
elif product_response.status == 404:
|
||||||
logger.warning(
|
logger.warning("e-conomic product %s does not exist", product_number)
|
||||||
"e-conomic product %s does not exist; exporting matching lines as free text",
|
|
||||||
product_number,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
product_text = await product_response.text()
|
product_text = await product_response.text()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -224,11 +282,51 @@ class OrdreEconomicExportService:
|
|||||||
detail=_economic_error_message(product_response.status, product_text),
|
detail=_economic_error_message(product_response.status, product_text),
|
||||||
)
|
)
|
||||||
|
|
||||||
skipped_product_numbers = sorted(product_numbers - valid_product_numbers)
|
missing_product_numbers = sorted(product_numbers - valid_product_numbers)
|
||||||
for line in economic_lines:
|
if missing_product_numbers:
|
||||||
product_number = str(line.get("product", {}).get("productNumber") or "").strip()
|
async with session.get(
|
||||||
if product_number and product_number not in valid_product_numbers:
|
f"{self.api_url}/product-groups?pagesize=1000",
|
||||||
line.pop("product", None)
|
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 {}
|
customer_layout = economic_customer.get("layout") or {}
|
||||||
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout
|
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout
|
||||||
@ -254,6 +352,10 @@ class OrdreEconomicExportService:
|
|||||||
|
|
||||||
if notes:
|
if notes:
|
||||||
payload["notes"] = {"textLine1": str(notes)[:1000]}
|
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:
|
if not write_allowed:
|
||||||
return {
|
return {
|
||||||
@ -267,13 +369,15 @@ class OrdreEconomicExportService:
|
|||||||
"read_only": self.read_only,
|
"read_only": self.read_only,
|
||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"skipped_product_numbers": skipped_product_numbers,
|
"skipped_product_numbers": missing_product_numbers,
|
||||||
|
"missing_product_numbers": missing_product_numbers,
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
|
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(
|
async with session.post(
|
||||||
f"{self.api_url}/orders/drafts",
|
f"{self.api_url}/orders/drafts",
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
@ -302,8 +406,15 @@ class OrdreEconomicExportService:
|
|||||||
"customer_name": customer.get("name"),
|
"customer_name": customer.get("name"),
|
||||||
"selected_line_count": len(selected_lines),
|
"selected_line_count": len(selected_lines),
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"skipped_product_numbers": skipped_product_numbers,
|
"skipped_product_numbers": missing_product_numbers,
|
||||||
|
"missing_product_numbers": missing_product_numbers,
|
||||||
"economic_response": export_result,
|
"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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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):
|
class OrdreLineInput(BaseModel):
|
||||||
@ -25,7 +60,9 @@ class OrdreLineInput(BaseModel):
|
|||||||
discount_percentage: float = Field(default=0, ge=0, le=100)
|
discount_percentage: float = Field(default=0, ge=0, le=100)
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
product_id: Optional[int] = None
|
product_id: Optional[int] = None
|
||||||
|
ean: Optional[str] = None
|
||||||
selected: bool = True
|
selected: bool = True
|
||||||
|
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
|
||||||
|
|
||||||
|
|
||||||
class OrdreExportRequest(BaseModel):
|
class OrdreExportRequest(BaseModel):
|
||||||
@ -35,6 +72,8 @@ class OrdreExportRequest(BaseModel):
|
|||||||
layout_number: Optional[int] = None
|
layout_number: Optional[int] = None
|
||||||
draft_id: Optional[int] = None
|
draft_id: Optional[int] = None
|
||||||
force_export: bool = False
|
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):
|
class OrdreDraftUpsertRequest(BaseModel):
|
||||||
@ -49,6 +88,15 @@ class OrdreDraftConsolidateRequest(BaseModel):
|
|||||||
draft_ids: List[int] = Field(..., min_length=2)
|
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:
|
def _safe_json_field(value: Any) -> Any:
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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."""
|
"""Export selected ordre lines to e-conomic draft order."""
|
||||||
try:
|
try:
|
||||||
user_id = _get_user_id_from_request(http_request)
|
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
|
previous_status = None
|
||||||
export_idempotency_key = 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]
|
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(
|
export_result = await ordre_economic_export_service.export_order(
|
||||||
customer_id=request.customer_id,
|
customer_id=request.customer_id,
|
||||||
lines=line_payload,
|
lines=line_payload,
|
||||||
notes=request.notes,
|
notes=request.notes,
|
||||||
layout_number=request.layout_number,
|
layout_number=request.layout_number,
|
||||||
user_id=user_id,
|
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")]
|
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(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
economic_order_number = (
|
economic_order_number = _economic_order_number(export_result)
|
||||||
export_result.get("economic_order_number")
|
|
||||||
or export_result.get("order_number")
|
|
||||||
or export_result.get("orderNumber")
|
|
||||||
)
|
|
||||||
economic_invoice_number = (
|
economic_invoice_number = (
|
||||||
export_result.get("economic_invoice_number")
|
export_result.get("economic_invoice_number")
|
||||||
or export_result.get("invoice_number")
|
or export_result.get("invoice_number")
|
||||||
or export_result.get("invoiceNumber")
|
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"
|
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
|
||||||
|
|
||||||
execute_query(
|
execute_query(
|
||||||
@ -219,6 +289,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
export_idempotency_key = %s,
|
export_idempotency_key = %s,
|
||||||
economic_order_number = COALESCE(%s, economic_order_number),
|
economic_order_number = COALESCE(%s, economic_order_number),
|
||||||
economic_invoice_number = COALESCE(%s, economic_invoice_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_sync_at = CURRENT_TIMESTAMP,
|
||||||
last_exported_at = CURRENT_TIMESTAMP,
|
last_exported_at = CURRENT_TIMESTAMP,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
@ -230,6 +311,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
export_idempotency_key,
|
export_idempotency_key,
|
||||||
str(economic_order_number) if economic_order_number is not None else None,
|
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,
|
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,
|
request.draft_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -244,12 +335,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
"idempotency_key": export_idempotency_key,
|
"idempotency_key": export_idempotency_key,
|
||||||
"economic_order_number": economic_order_number,
|
"economic_order_number": economic_order_number,
|
||||||
"economic_invoice_number": economic_invoice_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,
|
user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return export_result
|
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
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
|
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):
|
async def get_ordre_draft(draft_id: int, http_request: Request):
|
||||||
"""Get single ordre draft with lines payload (no user filtering)."""
|
"""Get single ordre draft with lines payload (no user filtering)."""
|
||||||
try:
|
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,)
|
params = (draft_id,)
|
||||||
|
|
||||||
from app.core.database import execute_query_single
|
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")
|
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")
|
@router.post("/ordre/drafts")
|
||||||
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
|
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
|
||||||
"""Create a new ordre draft."""
|
"""Create a new ordre draft."""
|
||||||
try:
|
try:
|
||||||
|
await _check_draft_prices(http_request, request.customer_id, request.lines)
|
||||||
user_id = _get_user_id_from_request(http_request)
|
user_id = _get_user_id_from_request(http_request)
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
|
||||||
@ -342,7 +491,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
|
|||||||
params = (
|
params = (
|
||||||
request.title,
|
request.title,
|
||||||
request.customer_id,
|
request.customer_id,
|
||||||
json.dumps(request.lines, ensure_ascii=False),
|
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
|
||||||
request.notes,
|
request.notes,
|
||||||
request.layout_number,
|
request.layout_number,
|
||||||
user_id,
|
user_id,
|
||||||
@ -350,6 +499,8 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
|
|||||||
)
|
)
|
||||||
result = execute_query(query, params)
|
result = execute_query(query, params)
|
||||||
return result[0]
|
return result[0]
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
|
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Failed to create ordre draft")
|
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 = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
|
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 = 'exported') AS exported_count,
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_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 {
|
return execute_query_single(query, ()) or {
|
||||||
"pending_count": 0,
|
"pending_count": 0,
|
||||||
|
"uncertain_count": 0,
|
||||||
"exported_count": 0,
|
"exported_count": 0,
|
||||||
"failed_count": 0,
|
"failed_count": 0,
|
||||||
"posted_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
|
from app.core.database import execute_query_single
|
||||||
previous = 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,)
|
(draft_id,)
|
||||||
)
|
)
|
||||||
if not previous:
|
if not previous:
|
||||||
raise HTTPException(status_code=404, detail="Draft not found")
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
from_status = (previous.get("sync_status") or "pending").strip().lower()
|
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)
|
values.append(draft_id)
|
||||||
from app.core.database import execute_query
|
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):
|
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
|
||||||
"""Update existing ordre draft."""
|
"""Update existing ordre draft."""
|
||||||
try:
|
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
|
from app.core.database import execute_query
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
@ -541,7 +709,7 @@ async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, ht
|
|||||||
params = (
|
params = (
|
||||||
request.title,
|
request.title,
|
||||||
request.customer_id,
|
request.customer_id,
|
||||||
json.dumps(request.lines, ensure_ascii=False),
|
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
|
||||||
request.notes,
|
request.notes,
|
||||||
request.layout_number,
|
request.layout_number,
|
||||||
draft_id,
|
draft_id,
|
||||||
|
|||||||
@ -344,9 +344,9 @@
|
|||||||
const index = line.originalIndex;
|
const index = line.originalIndex;
|
||||||
const isManual = line.source_type === 'manual';
|
const isManual = line.source_type === 'manual';
|
||||||
const descriptionField = isManual
|
const descriptionField = isManual
|
||||||
? `<input type="text" class="form-control form-control-sm" 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;">`
|
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
|
||||||
: escapeHtml(line.description || '-');
|
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
|
||||||
|
|
||||||
const manualActions = isManual
|
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-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-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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -95,8 +97,9 @@
|
|||||||
<i class="bi bi-shield-exclamation me-1"></i>
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
||||||
</div>
|
</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">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="forceExportToggle">
|
<input class="form-check-input" type="checkbox" id="forceExportToggle">
|
||||||
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
|
<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-item">
|
||||||
<div class="info-label">Kunde ID</div>
|
<div class="info-label">Kunde ID</div>
|
||||||
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
|
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
|
||||||
|
<div id="customerIdentity" class="small text-muted mt-1">-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<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 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>
|
||||||
|
|
||||||
|
<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="sync-card">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
|
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
|
||||||
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
|
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<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)">
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
|
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
|
||||||
</button>
|
</button>
|
||||||
@ -166,6 +194,7 @@
|
|||||||
<div class="sync-label">Sync status</div>
|
<div class="sync-label">Sync status</div>
|
||||||
<select id="syncStatusSelect" class="form-select form-select-sm">
|
<select id="syncStatusSelect" class="form-select form-select-sm">
|
||||||
<option value="pending">pending</option>
|
<option value="pending">pending</option>
|
||||||
|
<option value="uncertain">uncertain</option>
|
||||||
<option value="exported">exported</option>
|
<option value="exported">exported</option>
|
||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
<option value="posted">posted</option>
|
<option value="posted">posted</option>
|
||||||
@ -176,7 +205,7 @@
|
|||||||
<div class="sync-label">e-conomic ordre nr.</div>
|
<div class="sync-label">e-conomic ordre nr.</div>
|
||||||
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
|
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3" id="invoiceNumberEditWrap">
|
||||||
<div class="sync-label">e-conomic faktura nr.</div>
|
<div class="sync-label">e-conomic faktura nr.</div>
|
||||||
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
|
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
|
||||||
</div>
|
</div>
|
||||||
@ -200,15 +229,19 @@
|
|||||||
<div class="sync-label">Ordrenummer</div>
|
<div class="sync-label">Ordrenummer</div>
|
||||||
<div id="economicOrderNumberView" class="sync-value">-</div>
|
<div id="economicOrderNumberView" class="sync-value">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3" id="invoiceNumberViewWrap">
|
||||||
<div class="sync-label">Fakturanummer</div>
|
<div class="sync-label">Fakturanummer</div>
|
||||||
<div id="economicInvoiceNumberView" class="sync-value">-</div>
|
<div id="economicInvoiceNumberView" class="sync-value">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="sync-label">Betalingsstatus</div>
|
||||||
|
<div id="economicPaymentStatusView" class="sync-value">-</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-2 align-items-end mb-3">
|
<div class="row g-2 align-items-end mb-3">
|
||||||
<div class="col-md-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">
|
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
@ -292,6 +325,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -304,6 +368,8 @@
|
|||||||
let syncEventsOffset = 0;
|
let syncEventsOffset = 0;
|
||||||
let syncEventsTotal = 0;
|
let syncEventsTotal = 0;
|
||||||
let detailToast = null;
|
let detailToast = null;
|
||||||
|
let pendingEconomicProductPreview = null;
|
||||||
|
let pendingEconomicUpdateId = null;
|
||||||
|
|
||||||
function showToast(message, variant = 'dark') {
|
function showToast(message, variant = 'dark') {
|
||||||
const toastEl = document.getElementById('detailToast');
|
const toastEl = document.getElementById('detailToast');
|
||||||
@ -322,8 +388,8 @@
|
|||||||
detailToast.show();
|
detailToast.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value) {
|
function formatCurrency(value, currency = 'DKK') {
|
||||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
@ -354,6 +420,7 @@
|
|||||||
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
|
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 === 'exported') return '<span class="badge bg-primary">exported</span>';
|
||||||
if (normalized === 'failed') return '<span class="badge bg-danger">failed</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>';
|
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('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
|
||||||
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
|
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
|
||||||
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_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() {
|
function renderLines() {
|
||||||
@ -378,9 +471,11 @@
|
|||||||
|
|
||||||
tbody.innerHTML = orderLines.map((line, index) => {
|
tbody.innerHTML = orderLines.map((line, index) => {
|
||||||
const isExportedLine = line.export_status === 'exported';
|
const isExportedLine = line.export_status === 'exported';
|
||||||
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
|
||||||
${isExportedLine ? 'disabled' : ''}
|
const lockLine = isExportedLine && !mayEditExportedDraft;
|
||||||
onchange="orderLines[${index}].description = this.value;">`;
|
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 exportStatus = line.export_status || '-';
|
||||||
const statusBadge = exportStatus === 'exported'
|
const statusBadge = exportStatus === 'exported'
|
||||||
@ -406,17 +501,17 @@
|
|||||||
<td>${descriptionField}</td>
|
<td>${descriptionField}</td>
|
||||||
<td style="min-width:100px;">
|
<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)}"
|
<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});">
|
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width:120px;">
|
<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)}"
|
<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});">
|
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width:110px;">
|
<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)}"
|
<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});">
|
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||||||
</td>
|
</td>
|
||||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||||
@ -502,7 +597,7 @@
|
|||||||
const product = data.product;
|
const product = data.product;
|
||||||
line.product_id = product.id || line.product_id || null;
|
line.product_id = product.id || line.product_id || null;
|
||||||
line.description = product.name || product.product_name || line.description || '';
|
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);
|
line.unit_price = Number(product.sales_price || 0);
|
||||||
}
|
}
|
||||||
if (product.ean) {
|
if (product.ean) {
|
||||||
@ -542,7 +637,8 @@
|
|||||||
discount_percentage: Number(line.discountPercentage || 0),
|
discount_percentage: Number(line.discountPercentage || 0),
|
||||||
amount: Number(line.totalNetAmount || 0),
|
amount: Number(line.totalNetAmount || 0),
|
||||||
unit: line.unit || 'stk',
|
unit: line.unit || 'stk',
|
||||||
product_id: line.product.productNumber || null,
|
product_id: null,
|
||||||
|
economic_product_number: line.product.productNumber || null,
|
||||||
selected: line.selected !== false,
|
selected: line.selected !== false,
|
||||||
export_status: line.export_status || null,
|
export_status: line.export_status || null,
|
||||||
};
|
};
|
||||||
@ -569,13 +665,25 @@
|
|||||||
|
|
||||||
document.getElementById('orderTitle').value = orderData.title || '';
|
document.getElementById('orderTitle').value = orderData.title || '';
|
||||||
document.getElementById('customerId').value = orderData.customer_id || '';
|
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('layoutNumber').value = orderData.layout_number || '';
|
||||||
document.getElementById('orderNotes').value = orderData.notes || '';
|
document.getElementById('orderNotes').value = orderData.notes || '';
|
||||||
|
|
||||||
const hasExported = orderData.last_exported_at ? true : false;
|
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
|
||||||
document.getElementById('orderStatus').innerHTML = hasExported
|
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
|
||||||
|
document.getElementById('orderStatus').innerHTML = hasExported
|
||||||
? '<span class="badge bg-success">Eksporteret</span>'
|
? '<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('createdAt').textContent = formatDate(orderData.created_at);
|
||||||
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
||||||
@ -666,9 +774,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.innerHTML = items.map((event) => {
|
body.innerHTML = items.map((event) => {
|
||||||
const payload = typeof event.event_payload === 'object'
|
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
|
||||||
? JSON.stringify(event.event_payload, null, 2)
|
let payload = String(event.event_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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
@ -676,7 +793,7 @@
|
|||||||
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
|
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
|
||||||
<td>${escapeHtml(event.from_status || '-')}</td>
|
<td>${escapeHtml(event.from_status || '-')}</td>
|
||||||
<td>${escapeHtml(event.to_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>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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);
|
const customerId = Number(document.getElementById('customerId').value || 0);
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
showToast('Angiv kunde ID før eksport', 'warning');
|
showToast('Angiv kunde ID før eksport', 'warning');
|
||||||
@ -782,15 +991,18 @@
|
|||||||
description: line.description,
|
description: line.description,
|
||||||
quantity: Number(line.quantity || 0),
|
quantity: Number(line.quantity || 0),
|
||||||
unit_price: Number(line.unit_price || 0),
|
unit_price: Number(line.unit_price || 0),
|
||||||
|
currency: line.currency || 'DKK',
|
||||||
discount_percentage: Number(line.discount_percentage || 0),
|
discount_percentage: Number(line.discount_percentage || 0),
|
||||||
unit: line.unit || 'stk',
|
unit: line.unit || 'stk',
|
||||||
product_id: line.product_id || null,
|
product_id: line.product_id || null,
|
||||||
|
ean: line.ean || null,
|
||||||
selected: true,
|
selected: true,
|
||||||
})),
|
})),
|
||||||
notes: document.getElementById('orderNotes').value || null,
|
notes: document.getElementById('orderNotes').value || null,
|
||||||
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||||||
draft_id: draftId,
|
draft_id: draftId,
|
||||||
force_export: document.getElementById('forceExportToggle').checked,
|
force_export: document.getElementById('forceExportToggle').checked,
|
||||||
|
create_missing_products: createMissingProducts,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -801,6 +1013,10 @@
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (data.detail && data.detail.code === 'economic_products_missing') {
|
||||||
|
showEconomicProductPreview(data.detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(data.detail || 'Eksport fejlede');
|
throw new Error(data.detail || 'Eksport fejlede');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
import asyncio
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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]:
|
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_name = payload.get("supplier_name")
|
||||||
supplier_code = payload.get("supplier_code")
|
supplier_code = payload.get("supplier_code")
|
||||||
supplier_sku = payload.get("supplier_sku") or payload.get("sku")
|
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_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_url = payload.get("supplier_url") or payload.get("supplier_link")
|
||||||
supplier_product_url = (
|
supplier_product_url = (
|
||||||
payload.get("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
|
existing = None
|
||||||
if match_query and match_params is not 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)
|
existing = execute_query_single(match_query, match_params)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@ -129,6 +148,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
|||||||
supplier_url = %s,
|
supplier_url = %s,
|
||||||
supplier_product_url = %s,
|
supplier_product_url = %s,
|
||||||
source = %s,
|
source = %s,
|
||||||
|
variant_key = %s, license_term = %s, billing_interval = %s, market = %s, valid_until = %s,
|
||||||
last_updated_at = CURRENT_TIMESTAMP
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
@ -145,6 +165,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
|||||||
supplier_url,
|
supplier_url,
|
||||||
supplier_product_url,
|
supplier_product_url,
|
||||||
source,
|
source,
|
||||||
|
variant_key, license_term, billing_interval, market, valid_until,
|
||||||
existing.get("id"),
|
existing.get("id"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -162,8 +183,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
|||||||
supplier_url,
|
supplier_url,
|
||||||
supplier_product_url,
|
supplier_product_url,
|
||||||
source,
|
source,
|
||||||
|
variant_key, license_term, billing_interval, market, valid_until,
|
||||||
last_updated_at
|
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 *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
result = execute_query(
|
result = execute_query(
|
||||||
@ -179,9 +202,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
|||||||
supplier_url,
|
supplier_url,
|
||||||
supplier_product_url,
|
supplier_product_url,
|
||||||
source,
|
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:
|
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."""
|
"""Update product fields for core metadata and billing validation flags."""
|
||||||
try:
|
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")
|
name = payload.get("name")
|
||||||
if name is not None:
|
if name is not None:
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
if not name:
|
if not name or len(name) > 255:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise HTTPException(status_code=400, detail="Navnet skal være 1–255 tegn")
|
||||||
|
|
||||||
serial_number_required = payload.get("serial_number_required")
|
serial_number_required = payload.get("serial_number_required")
|
||||||
asset_required = payload.get("asset_required")
|
asset_required = payload.get("asset_required")
|
||||||
@ -980,8 +1006,8 @@ async def update_product(
|
|||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
updates = ["updated_at = CURRENT_TIMESTAMP"]
|
updates = ["updated_at = CURRENT_TIMESTAMP", "updated_by = %s"]
|
||||||
values: List[Any] = []
|
values: List[Any] = [current_user.get("id")]
|
||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
updates.append("name = %s")
|
updates.append("name = %s")
|
||||||
|
|||||||
@ -82,7 +82,7 @@
|
|||||||
<div class="badge-soft" id="productStatus">-</div>
|
<div class="badge-soft" id="productStatus">-</div>
|
||||||
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
|
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
|
||||||
<div class="product-muted" id="productSku">-</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 class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,7 +116,7 @@
|
|||||||
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
|
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
|
||||||
<div class="small mt-3" id="priceUpdateMessage"></div>
|
<div class="small mt-3" id="priceUpdateMessage"></div>
|
||||||
</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>
|
<h5 class="mb-3">Opdater leverandør</h5>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Leverandør</label>
|
<label class="form-label">Leverandør</label>
|
||||||
@ -191,7 +191,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||||
<h5 class="mb-0">Grosister og priser</h5>
|
<h5 class="mb-0">Grosister og priser</h5>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
@ -258,6 +258,13 @@
|
|||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
|
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
|
||||||
</div>
|
</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="col-12">
|
||||||
<div class="small" id="supplierListMessage"></div>
|
<div class="small" id="supplierListMessage"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -389,6 +396,10 @@ async function loadProductDetail() {
|
|||||||
const res = await fetch(`/api/v1/products/${productId}`);
|
const res = await fetch(`/api/v1/products/${productId}`);
|
||||||
if (!res.ok) throw new Error('Kunne ikke hente produkt');
|
if (!res.ok) throw new Error('Kunne ikke hente produkt');
|
||||||
const product = await res.json();
|
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('productName').textContent = product.name || 'Produkt';
|
||||||
document.getElementById('productMeta').textContent = [
|
document.getElementById('productMeta').textContent = [
|
||||||
product.manufacturer,
|
product.manufacturer,
|
||||||
@ -482,11 +493,11 @@ async function loadSupplierList() {
|
|||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
|
<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_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
|
||||||
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
|
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
|
||||||
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</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>
|
<td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@ -525,6 +536,11 @@ async function submitSupplierList() {
|
|||||||
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
|
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
|
||||||
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
|
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
|
||||||
supplier_product_url: document.getElementById('supplierListUrl').value.trim() || 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'
|
source: 'manual'
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -708,7 +724,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadProductDetail();
|
loadProductDetail();
|
||||||
loadPriceHistory();
|
loadPriceHistory();
|
||||||
loadSalesHistory();
|
loadSalesHistory();
|
||||||
loadSupplierList();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% 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>
|
<p>Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="products-actions">
|
<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()">
|
<button class="btn btn-primary" onclick="openCreateProductModal()">
|
||||||
<i class="bi bi-plus-circle"></i> Opret Produkt
|
<i class="bi bi-plus-circle"></i> Opret Produkt
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -287,10 +287,9 @@ class EconomicService:
|
|||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
|
# Customer creation is allowed, updates are deliberately not.
|
||||||
logger.warning(f"⚠️ e-conomic update blocked by safety flags (READ_ONLY={settings.ECONOMIC_READ_ONLY}, DRY_RUN={settings.ECONOMIC_DRY_RUN})")
|
logger.warning('e-conomic customer update blocked by the Hub write policy')
|
||||||
logger.info(f"Would update customer {customer_number} with: {update_data}")
|
return False
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@ -379,8 +378,8 @@ class EconomicService:
|
|||||||
Returns:
|
Returns:
|
||||||
Created supplier data with supplierNumber or None if failed
|
Created supplier data with supplierNumber or None if failed
|
||||||
"""
|
"""
|
||||||
if not self._check_write_permission("create_supplier"):
|
logger.warning('e-conomic supplier creation blocked by the Hub write policy')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build supplier payload
|
# Build supplier payload
|
||||||
@ -824,8 +823,8 @@ class EconomicService:
|
|||||||
Dict with voucher details or error info
|
Dict with voucher details or error info
|
||||||
"""
|
"""
|
||||||
# 🚨 SAFETY CHECK
|
# 🚨 SAFETY CHECK
|
||||||
if not self._check_write_permission("create_journal_supplier_invoice"):
|
logger.warning('e-conomic voucher posting blocked by the Hub write policy')
|
||||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract year from invoice date for accounting year
|
# Extract year from invoice date for accounting year
|
||||||
@ -1052,8 +1051,8 @@ class EconomicService:
|
|||||||
Dict with success status
|
Dict with success status
|
||||||
"""
|
"""
|
||||||
# 🚨 SAFETY CHECK
|
# 🚨 SAFETY CHECK
|
||||||
if not self._check_write_permission("upload_voucher_attachment"):
|
logger.warning('e-conomic voucher attachment blocked by the Hub write policy')
|
||||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read PDF file
|
# Read PDF file
|
||||||
|
|||||||
@ -162,6 +162,9 @@
|
|||||||
<a class="nav-link" href="#integrations" data-tab="integrations">
|
<a class="nav-link" href="#integrations" data-tab="integrations">
|
||||||
<i class="bi bi-plugin me-2"></i>Integrationer
|
<i class="bi bi-plugin me-2"></i>Integrationer
|
||||||
</a>
|
</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">
|
<a class="nav-link" href="#telefoni" data-tab="telefoni">
|
||||||
<i class="bi bi-telephone me-2"></i>Telefoni
|
<i class="bi bi-telephone me-2"></i>Telefoni
|
||||||
</a>
|
</a>
|
||||||
@ -263,6 +266,7 @@
|
|||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<h5 class="mb-4 fw-bold">e-conomic</h5>
|
<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 id="economicSettings">
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import execute_query, execute_update, execute_query_single
|
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 (
|
from app.timetracking.backend.models import (
|
||||||
TModuleEconomicExportRequest,
|
TModuleEconomicExportRequest,
|
||||||
TModuleEconomicExportResult
|
TModuleEconomicExportResult
|
||||||
@ -195,6 +196,43 @@ class EconomicExportService:
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Order has no lines"
|
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
|
# Log export start
|
||||||
audit.log_export_started(
|
audit.log_export_started(
|
||||||
@ -437,6 +475,7 @@ class EconomicExportService:
|
|||||||
|
|
||||||
# Call e-conomic API
|
# Call e-conomic API
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
|
assert_economic_write_allowed("POST", "/orders/drafts")
|
||||||
async with session.post(
|
async with session.post(
|
||||||
f"{self.api_url}/orders/drafts",
|
f"{self.api_url}/orders/drafts",
|
||||||
headers=self._get_headers(),
|
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.
|
||||||
@ -4,6 +4,7 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
|
|||||||
|
|
||||||
## Aktive planer
|
## 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.
|
- [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.
|
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
17
main.py
17
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.backend import router as subscriptions_api
|
||||||
from app.subscriptions.frontend import views as subscriptions_views
|
from app.subscriptions.frontend import views as subscriptions_views
|
||||||
from app.products.backend import router as products_api
|
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.products.frontend import views as products_views
|
||||||
from app.ticket.backend import router as ticket_api
|
from app.ticket.backend import router as ticket_api
|
||||||
from app.ticket.frontend import views as ticket_views
|
from app.ticket.frontend import views as ticket_views
|
||||||
@ -226,6 +227,17 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)")
|
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:
|
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
|
||||||
from app.jobs.eset_sync import run_eset_sync
|
from app.jobs.eset_sync import run_eset_sync
|
||||||
|
|
||||||
@ -323,6 +335,9 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("✅ Invoice Error Finder sync job scheduled (daily at 05:00)")
|
logger.info("✅ Invoice Error Finder sync job scheduled (daily at 05:00)")
|
||||||
|
|
||||||
logger.info("✅ System initialized successfully")
|
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
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
backup_scheduler.stop()
|
backup_scheduler.stop()
|
||||||
@ -485,6 +500,8 @@ app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
|||||||
app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
|
app.include_router(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(subscriptions_api.router, prefix="/api/v1", tags=["Subscriptions"])
|
||||||
app.include_router(products_api.router, prefix="/api/v1", tags=["Products"])
|
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(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
||||||
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||||
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
||||||
|
|||||||
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;
|
||||||
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);
|
||||||
|
})();
|
||||||
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
|
||||||
@ -47,3 +47,62 @@ def test_economic_validation_error_is_exposed_without_developer_metadata():
|
|||||||
assert "paymentTerms" in message
|
assert "paymentTerms" in message
|
||||||
assert "Required" in message
|
assert "Required" in message
|
||||||
assert "internal" not 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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user