From 631fc6cdca90ad6849a546bb248c5c59b3dcce42 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 13 Sep 2026 02:30:52 +0200 Subject: [PATCH] Implement four-eyes approval process for e-conomic changes - Added migration to create economic_change_requests table for tracking change requests for customers and products. - Introduced new permissions for requesting and approving changes in e-conomic. - Developed frontend JavaScript functionality for managing economic catalog, including handling change requests and displaying their statuses. - Created browser tests to validate UI interactions related to product management and change requests. - Added unit tests for backend logic to ensure proper handling of product numbers, price rules, and economic write policies. --- app/core/economic_write_policy.py | 42 ++ app/modules/orders/backend/economic_export.py | 12 + app/modules/orders/backend/router.py | 40 +- app/modules/orders/templates/detail.html | 24 +- app/products/backend/economic_access.py | 58 ++ app/products/backend/economic_catalog.py | 397 ++++++++++++ app/products/backend/economic_documents.py | 242 ++++++++ app/products/backend/economic_pricing.py | 77 +++ app/products/backend/economic_routes.py | 579 ++++++++++++++++++ app/products/backend/router.py | 44 +- app/products/frontend/detail.html | 27 +- app/products/frontend/economic.html | 41 ++ app/products/frontend/list.html | 1 + app/services/economic_service.py | 19 +- app/settings/frontend/settings.html | 4 + app/timetracking/backend/economic_export.py | 39 ++ docs/ECONOMIC_PRODUCT_CATALOG.md | 24 + fremtidige planer/README.md | 1 + fremtidige planer/vareintegration-economic.md | 210 +++++++ main.py | 6 + .../242_economic_product_integration.sql | 152 +++++ migrations/243_economic_four_eyes_changes.sql | 37 ++ static/js/economic-catalog.js | 105 ++++ tests/browser/economic_catalog.cjs | 65 ++ tests/test_economic_catalog.py | 349 +++++++++++ 25 files changed, 2566 insertions(+), 29 deletions(-) create mode 100644 app/core/economic_write_policy.py create mode 100644 app/products/backend/economic_access.py create mode 100644 app/products/backend/economic_catalog.py create mode 100644 app/products/backend/economic_documents.py create mode 100644 app/products/backend/economic_pricing.py create mode 100644 app/products/backend/economic_routes.py create mode 100644 app/products/frontend/economic.html create mode 100644 docs/ECONOMIC_PRODUCT_CATALOG.md create mode 100644 fremtidige planer/vareintegration-economic.md create mode 100644 migrations/242_economic_product_integration.sql create mode 100644 migrations/243_economic_four_eyes_changes.sql create mode 100644 static/js/economic-catalog.js create mode 100644 tests/browser/economic_catalog.cjs create mode 100644 tests/test_economic_catalog.py diff --git a/app/core/economic_write_policy.py b/app/core/economic_write_policy.py new file mode 100644 index 0000000..f09b228 --- /dev/null +++ b/app/core/economic_write_policy.py @@ -0,0 +1,42 @@ +"""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) +_FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$') + + +@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) + + +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() + ) + if operation not in ALLOWED_WRITES and not approved_update: + raise HTTPException( + status_code=403, + detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder', + ) diff --git a/app/modules/orders/backend/economic_export.py b/app/modules/orders/backend/economic_export.py index 9218028..4070f24 100644 --- a/app/modules/orders/backend/economic_export.py +++ b/app/modules/orders/backend/economic_export.py @@ -9,6 +9,7 @@ from fastapi import HTTPException from app.core.config import settings from app.core.database import execute_query, execute_query_single +from app.core.economic_write_policy import assert_economic_write_allowed logger = logging.getLogger(__name__) @@ -97,7 +98,17 @@ class OrdreEconomicExportService: notes: Optional[str] = None, layout_number: Optional[int] = None, user_id: Optional[int] = None, + document_key: Optional[str] = None, + currency: str = 'DKK', ) -> 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) customer = execute_query_single( "SELECT id, name, economic_customer_number FROM customers WHERE id = %s", (customer_id,), @@ -274,6 +285,7 @@ class OrdreEconomicExportService: logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str)) + assert_economic_write_allowed("POST", "/orders/drafts") async with session.post( f"{self.api_url}/orders/drafts", headers=self._headers(), diff --git a/app/modules/orders/backend/router.py b/app/modules/orders/backend/router.py index 5bb5b61..26e0b6e 100644 --- a/app/modules/orders/backend/router.py +++ b/app/modules/orders/backend/router.py @@ -15,6 +15,25 @@ router = APIRouter() ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"} +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): line_key: str source_type: str @@ -26,6 +45,7 @@ class OrdreLineInput(BaseModel): unit: Optional[str] = None product_id: Optional[int] = None selected: bool = True + currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') class OrdreExportRequest(BaseModel): @@ -35,6 +55,7 @@ class OrdreExportRequest(BaseModel): layout_number: Optional[int] = None draft_id: Optional[int] = None force_export: bool = False + currency: str = Field(default='DKK', pattern='^[A-Z]{3}$') class OrdreDraftUpsertRequest(BaseModel): @@ -138,6 +159,14 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): """Export selected ordre lines to e-conomic draft order.""" try: user_id = _get_user_id_from_request(http_request) + from app.products.backend.economic_documents import active_connection + if active_connection(): + 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') + user_id = actor['id'] previous_status = None export_idempotency_key = None @@ -173,12 +202,15 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request): ) line_payload = [line.model_dump() for line in request.lines] + await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id) export_result = await ordre_economic_export_service.export_order( customer_id=request.customer_id, lines=line_payload, notes=request.notes, layout_number=request.layout_number, user_id=user_id, + document_key=f'order-draft:{request.draft_id}' if request.draft_id else None, + currency=request.currency, ) exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] @@ -322,6 +354,7 @@ async def get_ordre_draft(draft_id: int, http_request: Request): async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request): """Create a new ordre draft.""" try: + await _check_draft_prices(http_request, request.customer_id, request.lines) user_id = _get_user_id_from_request(http_request) from app.core.database import execute_query @@ -342,7 +375,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req params = ( request.title, request.customer_id, - json.dumps(request.lines, ensure_ascii=False), + json.dumps(_snapshot_lines(request.lines), ensure_ascii=False), request.notes, request.layout_number, user_id, @@ -350,6 +383,8 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req ) result = execute_query(query, params) return result[0] + except HTTPException: + raise except Exception as e: logger.error("❌ Error creating ordre draft: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to create ordre draft") @@ -525,6 +560,7 @@ async def list_ordre_draft_sync_events( async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): """Update existing ordre draft.""" try: + await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id) from app.core.database import execute_query query = """ @@ -541,7 +577,7 @@ async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, ht params = ( request.title, request.customer_id, - json.dumps(request.lines, ensure_ascii=False), + json.dumps(_snapshot_lines(request.lines), ensure_ascii=False), request.notes, request.layout_number, draft_id, diff --git a/app/modules/orders/templates/detail.html b/app/modules/orders/templates/detail.html index 82b3c18..c43de1b 100644 --- a/app/modules/orders/templates/detail.html +++ b/app/modules/orders/templates/detail.html @@ -88,6 +88,8 @@ + + Varekoblinger og eksportfejl @@ -502,7 +504,7 @@ const product = data.product; line.product_id = product.id || line.product_id || null; line.description = product.name || product.product_name || line.description || ''; - if (!Number(line.unit_price || 0) && product.sales_price != null) { + if (line.unit_price == null && product.sales_price != null) { line.unit_price = Number(product.sales_price || 0); } if (product.ean) { @@ -542,7 +544,8 @@ discount_percentage: Number(line.discountPercentage || 0), amount: Number(line.totalNetAmount || 0), unit: line.unit || 'stk', - product_id: line.product.productNumber || null, + product_id: null, + economic_product_number: line.product.productNumber || null, selected: line.selected !== false, export_status: line.export_status || null, }; @@ -761,6 +764,22 @@ } } + 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'); } + } + async function exportOrder() { const customerId = Number(document.getElementById('customerId').value || 0); if (!customerId) { @@ -782,6 +801,7 @@ description: line.description, quantity: Number(line.quantity || 0), unit_price: Number(line.unit_price || 0), + currency: line.currency || 'DKK', discount_percentage: Number(line.discount_percentage || 0), unit: line.unit || 'stk', product_id: line.product_id || null, diff --git a/app/products/backend/economic_access.py b/app/products/backend/economic_access.py new file mode 100644 index 0000000..67ac895 --- /dev/null +++ b/app/products/backend/economic_access.py @@ -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 diff --git a/app/products/backend/economic_catalog.py b/app/products/backend/economic_catalog.py new file mode 100644 index 0000000..c7ac377 --- /dev/null +++ b/app/products/backend/economic_catalog.py @@ -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()} diff --git a/app/products/backend/economic_documents.py b/app/products/backend/economic_documents.py new file mode 100644 index 0000000..4fd7283 --- /dev/null +++ b/app/products/backend/economic_documents.py @@ -0,0 +1,242 @@ +"""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 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' + return {'success': True, 'dry_run': False, key: row['economic_number'], + 'economic_draft_id': row['economic_number'], 'export_id': str(row['id']), + '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'): + 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 = 'BMC-HUB:' + str(row['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' + marker = 'BMC-HUB:' + str(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))) + expected = row['snapshot']['payload'] + 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 diff --git a/app/products/backend/economic_pricing.py b/app/products/backend/economic_pricing.py new file mode 100644 index 0000000..1098283 --- /dev/null +++ b/app/products/backend/economic_pricing.py @@ -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} diff --git a/app/products/backend/economic_routes.py b/app/products/backend/economic_routes.py new file mode 100644 index 0000000..45aa2fc --- /dev/null +++ b/app/products/backend/economic_routes.py @@ -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']) diff --git a/app/products/backend/router.py b/app/products/backend/router.py index 82f418f..8c90703 100644 --- a/app/products/backend/router.py +++ b/app/products/backend/router.py @@ -13,7 +13,8 @@ import json import asyncio logger = logging.getLogger(__name__) -router = APIRouter() +from app.products.backend.economic_access import ProductAccessRoute +router = APIRouter(route_class=ProductAccessRoute) def _apigw_headers() -> Dict[str, str]: @@ -78,9 +79,25 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s supplier_name = payload.get("supplier_name") supplier_code = payload.get("supplier_code") supplier_sku = payload.get("supplier_sku") or payload.get("sku") - supplier_price = payload.get("supplier_price") or payload.get("price") + supplier_price = payload.get("supplier_price") if payload.get("supplier_price") is not None else payload.get("price") supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK" - supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty") + supplier_stock = payload.get("supplier_stock") if payload.get("supplier_stock") is not None else payload.get("stock_qty") + variant_key = str(payload.get('variant_key') or '') + license_term = str(payload.get('license_term') or '') + billing_interval = str(payload.get('billing_interval') or '') + market = str(payload.get('market') or '').upper() + valid_until = payload.get('valid_until') or None + if supplier_price is not None: + from app.products.backend.economic_catalog import decimal + supplier_price = decimal(supplier_price) + if supplier_price < 0: + raise HTTPException(422, 'Leverandørprisen må ikke være negativ') + if valid_until: + from datetime import date + try: + valid_until = date.fromisoformat(str(valid_until)) + except ValueError: + raise HTTPException(422, 'Ugyldig gyldighedsdato') supplier_url = payload.get("supplier_url") or payload.get("supplier_link") supplier_product_url = ( payload.get("supplier_product_url") @@ -115,6 +132,8 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s existing = None if match_query and match_params is not None: + match_query = match_query.replace('LIMIT 1', "AND COALESCE(variant_key,'')=%s AND COALESCE(license_term,'')=%s AND COALESCE(billing_interval,'')=%s AND COALESCE(market,'')=%s LIMIT 1") + match_params = (*match_params, variant_key, license_term, billing_interval, market) existing = execute_query_single(match_query, match_params) if existing: @@ -129,6 +148,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s supplier_url = %s, supplier_product_url = %s, source = %s, + variant_key = %s, license_term = %s, billing_interval = %s, market = %s, valid_until = %s, last_updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING * @@ -145,6 +165,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s supplier_url, supplier_product_url, source, + variant_key, license_term, billing_interval, market, valid_until, existing.get("id"), ) ) @@ -162,8 +183,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s supplier_url, supplier_product_url, source, + variant_key, license_term, billing_interval, market, valid_until, last_updated_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING RETURNING * """ result = execute_query( @@ -179,9 +202,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s supplier_url, supplier_product_url, source, + variant_key, license_term, billing_interval, market, valid_until, ) ) - return result[0] if result else {} + return result[0] if result else (execute_query_single(match_query, match_params) if match_query else {}) def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None: @@ -959,11 +983,13 @@ async def update_product( ): """Update product fields for core metadata and billing validation flags.""" try: + if any(key in payload for key in ('economic_product_number', 'economic_connection_id')): + raise HTTPException(409, 'Den permanente e-conomic-kobling kan ikke ændres ved almindelig produktredigering') name = payload.get("name") if name is not None: name = name.strip() - if not name: - raise HTTPException(status_code=400, detail="name cannot be empty") + if not name or len(name) > 255: + raise HTTPException(status_code=400, detail="Navnet skal være 1–255 tegn") serial_number_required = payload.get("serial_number_required") asset_required = payload.get("asset_required") @@ -980,8 +1006,8 @@ async def update_product( if not existing: raise HTTPException(status_code=404, detail="Product not found") - updates = ["updated_at = CURRENT_TIMESTAMP"] - values: List[Any] = [] + updates = ["updated_at = CURRENT_TIMESTAMP", "updated_by = %s"] + values: List[Any] = [current_user.get("id")] if name is not None: updates.append("name = %s") diff --git a/app/products/frontend/detail.html b/app/products/frontend/detail.html index c05aec3..bb55a98 100644 --- a/app/products/frontend/detail.html +++ b/app/products/frontend/detail.html @@ -82,7 +82,7 @@
-
-
-
-
-
+
-
@@ -116,7 +116,7 @@
-
+
Opdater leverandør
@@ -191,7 +191,7 @@
-
+
Grosister og priser
@@ -258,6 +258,13 @@
+
Licensvariant og gyldighed (valgfrit)
+
+
+
+
+
+

Forskellige varianter, perioder og markeder gemmes som separate leverandørtilbud.

@@ -389,6 +396,10 @@ async function loadProductDetail() { const res = await fetch(`/api/v1/products/${productId}`); if (!res.ok) throw new Error('Kunne ikke hente produkt'); const product = await res.json(); + const permissions = product._permissions || {}; + document.querySelectorAll('[data-cost-section]').forEach(el => el.hidden = permissions.cost_view === false); + document.querySelectorAll('[onclick="submitPriceUpdate()"]').forEach(el => el.hidden = permissions.pricing === false); + if (permissions.cost_view !== false) await loadSupplierList(); document.getElementById('productName').textContent = product.name || 'Produkt'; document.getElementById('productMeta').textContent = [ product.manufacturer, @@ -482,11 +493,11 @@ async function loadSupplierList() { return ` ${escapeHtml(entry.supplier_name || entry.supplier_code || '-')} - ${escapeHtml(entry.supplier_sku || '-')} + ${escapeHtml(entry.supplier_sku || '-')}${escapeHtml([entry.variant_key,entry.license_term,entry.billing_interval,entry.market].filter(Boolean).join(' · '))} ${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'} ${entry.supplier_stock != null ? entry.supplier_stock : '-'} ${link ? `Link` : '-'} - ${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'} + ${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}${entry.valid_until ? `Gyldig til ${escapeHtml(entry.valid_until)}` : ''} `; @@ -525,6 +536,11 @@ async function submitSupplierList() { supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null, supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null, supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null, + variant_key: document.getElementById('supplierVariant').value.trim(), + license_term: document.getElementById('supplierTerm').value.trim(), + billing_interval: document.getElementById('supplierInterval').value.trim(), + market: document.getElementById('supplierMarket').value.trim(), + valid_until: document.getElementById('supplierValidUntil').value || null, source: 'manual' }; @@ -708,7 +724,6 @@ document.addEventListener('DOMContentLoaded', () => { loadProductDetail(); loadPriceHistory(); loadSalesHistory(); - loadSupplierList(); }); {% endblock %} diff --git a/app/products/frontend/economic.html b/app/products/frontend/economic.html new file mode 100644 index 0000000..31a09b4 --- /dev/null +++ b/app/products/frontend/economic.html @@ -0,0 +1,41 @@ +{% extends 'shared/frontend/base.html' %} +{% block title %}Varer og e-conomic – BMC Hub{% endblock %} +{% block content %} +
+

Varer og e-conomic

Produktkatalog
+

Importér varer, knyt Hub-produkter til e-conomic og følg synkroniseringen.

+
+
+
+ + + + + + +
+
+ +
+
+ +
Hub-IDVarenummerNavnHub-prisReferenceprisStatusHandling
+ +
+ + + + + +

+
+
+ +{% endblock %} diff --git a/app/products/frontend/list.html b/app/products/frontend/list.html index af67aa3..bad7b90 100644 --- a/app/products/frontend/list.html +++ b/app/products/frontend/list.html @@ -324,6 +324,7 @@

Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.

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

Hub opretter kun en ordrekladde. Fakturakladder, bogføring og afsendelse ændres aldrig fra Hub.

',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',`
`+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')+''+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',''+field('target_number','Kunde-/varenummer')+''+field('value','Ny værdi')+field('reason','Begrundelse (mindst 10 tegn)')+'

Forslaget ændrer intet nu. En anden godkender skal kontrollere før/efter og godkende.

',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,`

Dette er en særskilt ændring af varens permanente kobling. Historiske dokumenter ændres ikke. Nuværende nummer: ${esc(p.economic_product_number)}

`+field('confirmation_number','Skriv det nuværende nummer for at bekræfte')+field('number','Nyt eksisterende varenummer på den valgte aftale')+field('reason','Begrundelse (mindst 10 tegn)'),d=>api(`/products/${p.id}/economic-rebind`,'POST',{...d,connection_id:connection()}));} + if(b.dataset.price){const p=products.find(x=>x.id===Number(b.dataset.price));modal('Hub-pris: '+p.name,field('sales_price','Standardpris ekskl. moms','number',p.sales_price??'')+field('sales_currency','Valuta','text',p.sales_currency||'DKK')+'

Dette ændrer ikke referenceprisen i e-conomic eller eksisterende aftalepriser.

',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,''+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||'')+`
`,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)+`

Låst e-conomic-varenummer: ${esc(p.economic_product_number)}

Navnet gemmes kun i Hub. Det ændrer aldrig den eksisterende e-conomic-vare.

`+(p.economic_sync_status==='conflict'?'':''),d=>api(`/products/${p.id}`,'PATCH',{name:d.name}));else modal(p.name,''+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); +})(); diff --git a/tests/browser/economic_catalog.cjs b/tests/browser/economic_catalog.cjs new file mode 100644 index 0000000..3040e02 --- /dev/null +++ b/tests/browser/economic_catalog.cjs @@ -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 = '' + 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;}); diff --git a/tests/test_economic_catalog.py b/tests/test_economic_catalog.py new file mode 100644 index 0000000..7e552cb --- /dev/null +++ b/tests/test_economic_catalog.py @@ -0,0 +1,349 @@ +"""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' + + +@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_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