release: v2.8.12 add complete economic export receipt
This commit is contained in:
parent
a523bc4ac3
commit
4971f9721c
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# BMC Hub v2.8.12
|
||||||
|
|
||||||
|
## Eksportkvittering og afstemning
|
||||||
|
|
||||||
|
- Gemmer e-conomic-kunde, valuta, layout, nettobeløb, request, response, ekstern reference, oprettede varer og brugeren, der eksporterede.
|
||||||
|
- Sender den interne idempotency-reference med ordrekladden til e-conomic.
|
||||||
|
- En ordre kan ikke længere markeres som eksporteret, bogført eller betalt uden et e-conomic-ordrenummer.
|
||||||
|
- Eksisterende poster i den ugyldige kombination flyttes til `uncertain`, så de skal afstemmes før en eventuel ny eksport.
|
||||||
|
- Gemmer og viser den seneste eksportfejl.
|
||||||
|
|
||||||
|
## Ordrevisning
|
||||||
|
|
||||||
|
- Viser kundenavn sammen med Hub-ID og e-conomic-kundenummer.
|
||||||
|
- Viser en kompakt eksportkvittering med ordrenummer, periode, valuta, layout, nettobeløb, bruger og reference.
|
||||||
|
- Viser eksporthistorik som læsbare beskrivelser i stedet for rå JSON.
|
||||||
|
- Skjuler fakturanummer indtil bogføring og skjuler tvungen geneksport efter gennemført eksport.
|
||||||
|
- Retter eksisterende ALSO-abonnementskladder til titlen `Abonnementer`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
- Kør migration `245_ordre_export_receipt.sql` før applikationen opdateres.
|
||||||
@ -130,6 +130,7 @@ class OrdreEconomicExportService:
|
|||||||
document_key: Optional[str] = None,
|
document_key: Optional[str] = None,
|
||||||
currency: str = 'DKK',
|
currency: str = 'DKK',
|
||||||
create_missing_products: Optional[Dict[str, int]] = None,
|
create_missing_products: Optional[Dict[str, int]] = None,
|
||||||
|
export_reference: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
|
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
|
||||||
connection = active_connection()
|
connection = active_connection()
|
||||||
@ -178,6 +179,7 @@ class OrdreEconomicExportService:
|
|||||||
|
|
||||||
economic_lines: List[Dict[str, Any]] = []
|
economic_lines: List[Dict[str, Any]] = []
|
||||||
creation_candidates: Dict[str, Dict[str, Any]] = {}
|
creation_candidates: Dict[str, Dict[str, Any]] = {}
|
||||||
|
created_product_numbers: List[str] = []
|
||||||
for line in selected_lines:
|
for line in selected_lines:
|
||||||
try:
|
try:
|
||||||
quantity = float(line.get("quantity") or 0)
|
quantity = float(line.get("quantity") or 0)
|
||||||
@ -321,6 +323,7 @@ class OrdreEconomicExportService:
|
|||||||
if str(created.get("productNumber") or "") != number:
|
if str(created.get("productNumber") or "") != number:
|
||||||
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
|
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
|
||||||
valid_product_numbers.add(number)
|
valid_product_numbers.add(number)
|
||||||
|
created_product_numbers.append(number)
|
||||||
_require_valid_product_numbers(product_numbers, valid_product_numbers)
|
_require_valid_product_numbers(product_numbers, valid_product_numbers)
|
||||||
|
|
||||||
customer_layout = economic_customer.get("layout") or {}
|
customer_layout = economic_customer.get("layout") or {}
|
||||||
@ -347,6 +350,8 @@ class OrdreEconomicExportService:
|
|||||||
|
|
||||||
if notes:
|
if notes:
|
||||||
payload["notes"] = {"textLine1": str(notes)[:1000]}
|
payload["notes"] = {"textLine1": str(notes)[:1000]}
|
||||||
|
if export_reference:
|
||||||
|
payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]}
|
||||||
|
|
||||||
if not write_allowed:
|
if not write_allowed:
|
||||||
return {
|
return {
|
||||||
@ -400,6 +405,12 @@ class OrdreEconomicExportService:
|
|||||||
"skipped_product_numbers": missing_product_numbers,
|
"skipped_product_numbers": missing_product_numbers,
|
||||||
"missing_product_numbers": missing_product_numbers,
|
"missing_product_numbers": missing_product_numbers,
|
||||||
"economic_response": export_result,
|
"economic_response": export_result,
|
||||||
|
"request_payload": payload,
|
||||||
|
"economic_customer_number": customer_number,
|
||||||
|
"currency": payload["currency"],
|
||||||
|
"layout_number": int(resolved_layout_number),
|
||||||
|
"economic_order_url": export_result.get("self"),
|
||||||
|
"created_product_numbers": created_product_numbers,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from app.modules.orders.backend.service import aggregate_order_lines
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"}
|
||||||
|
|
||||||
|
|
||||||
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
|
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
|
||||||
@ -26,6 +26,11 @@ def _economic_order_number(export_result: Dict[str, Any]) -> Any:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
|
||||||
|
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
|
||||||
|
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_lines(lines):
|
def _snapshot_lines(lines):
|
||||||
from app.products.backend.economic_documents import active_connection, snapshot_draft_lines
|
from app.products.backend.economic_documents import active_connection, snapshot_draft_lines
|
||||||
return snapshot_draft_lines(lines) if active_connection() else lines
|
return snapshot_draft_lines(lines) if active_connection() else lines
|
||||||
@ -227,6 +232,7 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
|
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
|
||||||
currency=request.currency,
|
currency=request.currency,
|
||||||
create_missing_products=request.create_missing_products,
|
create_missing_products=request.create_missing_products,
|
||||||
|
export_reference=export_idempotency_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
|
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
|
||||||
@ -253,6 +259,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
or export_result.get("invoice_number")
|
or export_result.get("invoice_number")
|
||||||
or export_result.get("invoiceNumber")
|
or export_result.get("invoiceNumber")
|
||||||
)
|
)
|
||||||
|
if not export_result.get("dry_run") and not economic_order_number:
|
||||||
|
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
|
||||||
|
details = export_result.get("details") or {}
|
||||||
|
request_payload = details.get("request_payload") or {}
|
||||||
|
response_payload = details.get("economic_response") or {}
|
||||||
|
net_amount = sum(
|
||||||
|
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
|
||||||
|
* (1 - float(line.get("discountPercentage") or 0) / 100)
|
||||||
|
for line in (request_payload.get("lines") or [])
|
||||||
|
)
|
||||||
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
|
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
|
||||||
|
|
||||||
execute_query(
|
execute_query(
|
||||||
@ -263,6 +279,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
export_idempotency_key = %s,
|
export_idempotency_key = %s,
|
||||||
economic_order_number = COALESCE(%s, economic_order_number),
|
economic_order_number = COALESCE(%s, economic_order_number),
|
||||||
economic_invoice_number = COALESCE(%s, economic_invoice_number),
|
economic_invoice_number = COALESCE(%s, economic_invoice_number),
|
||||||
|
economic_export_id = %s,
|
||||||
|
economic_order_url = %s,
|
||||||
|
exported_economic_customer_number = %s,
|
||||||
|
exported_currency = %s,
|
||||||
|
exported_layout_number = %s,
|
||||||
|
exported_net_amount = %s,
|
||||||
|
export_request_json = %s::jsonb,
|
||||||
|
export_response_json = %s::jsonb,
|
||||||
|
exported_created_products = %s::jsonb,
|
||||||
|
last_export_error = NULL,
|
||||||
|
exported_by_user_id = %s,
|
||||||
last_sync_at = CURRENT_TIMESTAMP,
|
last_sync_at = CURRENT_TIMESTAMP,
|
||||||
last_exported_at = CURRENT_TIMESTAMP,
|
last_exported_at = CURRENT_TIMESTAMP,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
@ -274,6 +301,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
export_idempotency_key,
|
export_idempotency_key,
|
||||||
str(economic_order_number) if economic_order_number is not None else None,
|
str(economic_order_number) if economic_order_number is not None else None,
|
||||||
str(economic_invoice_number) if economic_invoice_number is not None else None,
|
str(economic_invoice_number) if economic_invoice_number is not None else None,
|
||||||
|
export_result.get("export_id") or export_idempotency_key,
|
||||||
|
details.get("economic_order_url"),
|
||||||
|
str(details.get("economic_customer_number") or "") or None,
|
||||||
|
details.get("currency"),
|
||||||
|
details.get("layout_number"),
|
||||||
|
net_amount,
|
||||||
|
json.dumps(request_payload, ensure_ascii=False),
|
||||||
|
json.dumps(response_payload, ensure_ascii=False),
|
||||||
|
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
|
||||||
|
user_id,
|
||||||
request.draft_id,
|
request.draft_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -288,12 +325,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
|||||||
"idempotency_key": export_idempotency_key,
|
"idempotency_key": export_idempotency_key,
|
||||||
"economic_order_number": economic_order_number,
|
"economic_order_number": economic_order_number,
|
||||||
"economic_invoice_number": economic_invoice_number,
|
"economic_invoice_number": economic_invoice_number,
|
||||||
|
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
|
||||||
|
"economic_customer_number": details.get("economic_customer_number"),
|
||||||
|
"currency": details.get("currency"),
|
||||||
|
"layout_number": details.get("layout_number"),
|
||||||
|
"net_amount": net_amount,
|
||||||
|
"created_product_numbers": details.get("created_product_numbers") or [],
|
||||||
},
|
},
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return export_result
|
return export_result
|
||||||
except HTTPException:
|
except HTTPException as exc:
|
||||||
|
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
|
||||||
|
if request.draft_id and not is_preview:
|
||||||
|
try:
|
||||||
|
from app.core.database import execute_query
|
||||||
|
execute_query(
|
||||||
|
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
||||||
|
(str(exc.detail)[:2000], request.draft_id),
|
||||||
|
)
|
||||||
|
_log_sync_event(
|
||||||
|
request.draft_id, "export_failed", previous_status, previous_status,
|
||||||
|
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
|
||||||
|
"idempotency_key": export_idempotency_key}, user_id,
|
||||||
|
)
|
||||||
|
except Exception as log_exc:
|
||||||
|
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
|
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
|
||||||
@ -344,7 +402,13 @@ async def list_ordre_drafts(
|
|||||||
async def get_ordre_draft(draft_id: int, http_request: Request):
|
async def get_ordre_draft(draft_id: int, http_request: Request):
|
||||||
"""Get single ordre draft with lines payload (no user filtering)."""
|
"""Get single ordre draft with lines payload (no user filtering)."""
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
|
query = """SELECT d.*, c.name AS customer_name,
|
||||||
|
c.economic_customer_number AS current_economic_customer_number,
|
||||||
|
u.full_name AS exported_by_name
|
||||||
|
FROM ordre_drafts d
|
||||||
|
LEFT JOIN customers c ON c.id = d.customer_id
|
||||||
|
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
|
||||||
|
WHERE d.id = %s LIMIT 1"""
|
||||||
params = (draft_id,)
|
params = (draft_id,)
|
||||||
|
|
||||||
from app.core.database import execute_query_single
|
from app.core.database import execute_query_single
|
||||||
@ -411,6 +475,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
|
|||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
|
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
|
||||||
|
COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count,
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
|
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
||||||
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
|
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
|
||||||
@ -420,6 +485,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
|
|||||||
"""
|
"""
|
||||||
return execute_query_single(query, ()) or {
|
return execute_query_single(query, ()) or {
|
||||||
"pending_count": 0,
|
"pending_count": 0,
|
||||||
|
"uncertain_count": 0,
|
||||||
"exported_count": 0,
|
"exported_count": 0,
|
||||||
"failed_count": 0,
|
"failed_count": 0,
|
||||||
"posted_count": 0,
|
"posted_count": 0,
|
||||||
@ -464,12 +530,13 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
|
|||||||
|
|
||||||
from app.core.database import execute_query_single
|
from app.core.database import execute_query_single
|
||||||
previous = execute_query_single(
|
previous = execute_query_single(
|
||||||
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
|
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s",
|
||||||
(draft_id,)
|
(draft_id,)
|
||||||
)
|
)
|
||||||
if not previous:
|
if not previous:
|
||||||
raise HTTPException(status_code=404, detail="Draft not found")
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
from_status = (previous.get("sync_status") or "pending").strip().lower()
|
from_status = (previous.get("sync_status") or "pending").strip().lower()
|
||||||
|
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
|
||||||
|
|
||||||
values.append(draft_id)
|
values.append(draft_id)
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
|||||||
@ -97,8 +97,9 @@
|
|||||||
<i class="bi bi-shield-exclamation me-1"></i>
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
||||||
</div>
|
</div>
|
||||||
|
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mb-3">
|
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="forceExportToggle">
|
<input class="form-check-input" type="checkbox" id="forceExportToggle">
|
||||||
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
|
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
|
||||||
@ -117,6 +118,7 @@
|
|||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<div class="info-label">Kunde ID</div>
|
<div class="info-label">Kunde ID</div>
|
||||||
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
|
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
|
||||||
|
<div id="customerIdentity" class="small text-muted mt-1">-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@ -147,11 +149,29 @@
|
|||||||
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
|
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="exportReceiptCard" class="card mb-3 d-none">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
|
||||||
|
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><div class="row g-3">
|
||||||
|
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
|
||||||
|
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
|
||||||
|
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
|
||||||
|
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
|
||||||
|
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
|
||||||
|
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
|
||||||
|
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
|
||||||
|
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
|
||||||
|
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sync-card">
|
<div class="sync-card">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
|
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
|
||||||
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
|
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
|
||||||
@ -168,6 +188,7 @@
|
|||||||
<div class="sync-label">Sync status</div>
|
<div class="sync-label">Sync status</div>
|
||||||
<select id="syncStatusSelect" class="form-select form-select-sm">
|
<select id="syncStatusSelect" class="form-select form-select-sm">
|
||||||
<option value="pending">pending</option>
|
<option value="pending">pending</option>
|
||||||
|
<option value="uncertain">uncertain</option>
|
||||||
<option value="exported">exported</option>
|
<option value="exported">exported</option>
|
||||||
<option value="failed">failed</option>
|
<option value="failed">failed</option>
|
||||||
<option value="posted">posted</option>
|
<option value="posted">posted</option>
|
||||||
@ -178,7 +199,7 @@
|
|||||||
<div class="sync-label">e-conomic ordre nr.</div>
|
<div class="sync-label">e-conomic ordre nr.</div>
|
||||||
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
|
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3" id="invoiceNumberEditWrap">
|
||||||
<div class="sync-label">e-conomic faktura nr.</div>
|
<div class="sync-label">e-conomic faktura nr.</div>
|
||||||
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
|
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
|
||||||
</div>
|
</div>
|
||||||
@ -202,7 +223,7 @@
|
|||||||
<div class="sync-label">Ordrenummer</div>
|
<div class="sync-label">Ordrenummer</div>
|
||||||
<div id="economicOrderNumberView" class="sync-value">-</div>
|
<div id="economicOrderNumberView" class="sync-value">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3" id="invoiceNumberViewWrap">
|
||||||
<div class="sync-label">Fakturanummer</div>
|
<div class="sync-label">Fakturanummer</div>
|
||||||
<div id="economicInvoiceNumberView" class="sync-value">-</div>
|
<div id="economicInvoiceNumberView" class="sync-value">-</div>
|
||||||
</div>
|
</div>
|
||||||
@ -210,7 +231,7 @@
|
|||||||
|
|
||||||
<div class="row g-2 align-items-end mb-3">
|
<div class="row g-2 align-items-end mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label mb-1">Event type</label>
|
<label class="form-label mb-1">Hændelse</label>
|
||||||
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
|
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
@ -349,8 +370,8 @@
|
|||||||
detailToast.show();
|
detailToast.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value) {
|
function formatCurrency(value, currency = 'DKK') {
|
||||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
@ -381,6 +402,7 @@
|
|||||||
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
|
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
|
||||||
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
|
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
|
||||||
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
|
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
|
||||||
|
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
|
||||||
return '<span class="badge bg-warning text-dark">pending</span>';
|
return '<span class="badge bg-warning text-dark">pending</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -393,6 +415,32 @@
|
|||||||
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
|
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
|
||||||
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
|
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
|
||||||
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
|
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
|
||||||
|
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
|
||||||
|
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
|
||||||
|
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
|
||||||
|
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
|
||||||
|
|
||||||
|
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
|
||||||
|
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
|
||||||
|
if (hasReceipt) {
|
||||||
|
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
|
||||||
|
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
|
||||||
|
const period = orderData.coverage_start && orderData.coverage_end
|
||||||
|
? `${orderData.coverage_start} – ${orderData.coverage_end}`
|
||||||
|
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
|
||||||
|
document.getElementById('receiptPeriod').textContent = period;
|
||||||
|
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
|
||||||
|
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
|
||||||
|
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
|
||||||
|
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
|
||||||
|
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
|
||||||
|
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
|
||||||
|
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
|
||||||
|
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
|
||||||
|
const link = document.getElementById('economicOrderLink');
|
||||||
|
link.classList.toggle('d-none', !orderData.economic_order_url);
|
||||||
|
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLines() {
|
function renderLines() {
|
||||||
@ -597,13 +645,21 @@
|
|||||||
|
|
||||||
document.getElementById('orderTitle').value = orderData.title || '';
|
document.getElementById('orderTitle').value = orderData.title || '';
|
||||||
document.getElementById('customerId').value = orderData.customer_id || '';
|
document.getElementById('customerId').value = orderData.customer_id || '';
|
||||||
|
document.getElementById('customerIdentity').textContent = orderData.customer_name
|
||||||
|
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
|
||||||
|
: '-';
|
||||||
document.getElementById('layoutNumber').value = orderData.layout_number || '';
|
document.getElementById('layoutNumber').value = orderData.layout_number || '';
|
||||||
document.getElementById('orderNotes').value = orderData.notes || '';
|
document.getElementById('orderNotes').value = orderData.notes || '';
|
||||||
|
|
||||||
const hasExported = orderData.last_exported_at ? true : false;
|
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
|
||||||
document.getElementById('orderStatus').innerHTML = hasExported
|
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
|
||||||
|
document.getElementById('orderStatus').innerHTML = hasExported
|
||||||
? '<span class="badge bg-success">Eksporteret</span>'
|
? '<span class="badge bg-success">Eksporteret</span>'
|
||||||
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
|
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>'
|
||||||
|
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||||
|
const errorBanner = document.getElementById('exportErrorBanner');
|
||||||
|
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
|
||||||
|
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
|
||||||
|
|
||||||
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
|
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
|
||||||
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
||||||
@ -694,9 +750,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.innerHTML = items.map((event) => {
|
body.innerHTML = items.map((event) => {
|
||||||
const payload = typeof event.event_payload === 'object'
|
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
|
||||||
? JSON.stringify(event.event_payload, null, 2)
|
let payload = String(event.event_payload || '');
|
||||||
: String(event.event_payload || '');
|
if (event.event_type === 'export_success') {
|
||||||
|
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
|
||||||
|
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
|
||||||
|
} else if (event.event_type === 'export_attempt') {
|
||||||
|
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
|
||||||
|
} else if (event.event_type === 'sync_status_manual_update') {
|
||||||
|
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
|
||||||
|
} else if (typeof event.event_payload === 'object') {
|
||||||
|
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
@ -704,7 +769,7 @@
|
|||||||
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
|
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
|
||||||
<td>${escapeHtml(event.from_status || '-')}</td>
|
<td>${escapeHtml(event.from_status || '-')}</td>
|
||||||
<td>${escapeHtml(event.to_status || '-')}</td>
|
<td>${escapeHtml(event.to_status || '-')}</td>
|
||||||
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
|
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@ -115,8 +115,16 @@ async def preflight(connection, customer_id, lines, layout_number=None, currency
|
|||||||
|
|
||||||
def result_for(row):
|
def result_for(row):
|
||||||
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
|
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
|
||||||
|
snapshot = row.get('snapshot') or {}
|
||||||
|
payload = snapshot.get('payload') or {}
|
||||||
|
response = row.get('response') or {}
|
||||||
return {'success': True, 'dry_run': False, key: row['economic_number'],
|
return {'success': True, 'dry_run': False, key: row['economic_number'],
|
||||||
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']),
|
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']),
|
||||||
|
'details': {'request_payload': payload, 'economic_response': response,
|
||||||
|
'economic_customer_number': (payload.get('customer') or {}).get('customerNumber'),
|
||||||
|
'currency': payload.get('currency'),
|
||||||
|
'layout_number': (payload.get('layout') or {}).get('layoutNumber'),
|
||||||
|
'economic_order_url': response.get('self'), 'created_product_numbers': []},
|
||||||
'message': 'Eksporteret til e-conomic-kladde'}
|
'message': 'Eksporteret til e-conomic-kladde'}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
32
migrations/245_ordre_export_receipt.sql
Normal file
32
migrations/245_ordre_export_receipt.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- Persist the evidence needed to reconcile an order draft with e-conomic.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE ordre_drafts
|
||||||
|
ADD COLUMN IF NOT EXISTS economic_export_id VARCHAR(120),
|
||||||
|
ADD COLUMN IF NOT EXISTS economic_order_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS exported_economic_customer_number VARCHAR(80),
|
||||||
|
ADD COLUMN IF NOT EXISTS exported_currency VARCHAR(3),
|
||||||
|
ADD COLUMN IF NOT EXISTS exported_layout_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS exported_net_amount NUMERIC(18,2),
|
||||||
|
ADD COLUMN IF NOT EXISTS export_request_json JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS export_response_json JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS exported_created_products JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_export_error TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS exported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE ordre_drafts DROP CONSTRAINT IF EXISTS ordre_drafts_sync_status_check;
|
||||||
|
ALTER TABLE ordre_drafts ADD CONSTRAINT ordre_drafts_sync_status_check
|
||||||
|
CHECK (sync_status IN ('pending', 'uncertain', 'exported', 'failed', 'posted', 'paid'));
|
||||||
|
|
||||||
|
UPDATE ordre_drafts
|
||||||
|
SET sync_status = 'uncertain',
|
||||||
|
last_export_error = COALESCE(last_export_error, 'Tidligere eksport: eksport markeret gennemført uden gemt e-conomic-ordrenummer; afstem manuelt før ny eksport')
|
||||||
|
WHERE sync_status IN ('exported', 'posted', 'paid')
|
||||||
|
AND economic_order_number IS NULL;
|
||||||
|
|
||||||
|
UPDATE ordre_drafts
|
||||||
|
SET title = 'Abonnementer'
|
||||||
|
WHERE invoice_aggregate_key LIKE 'also-cloud-%'
|
||||||
|
AND title LIKE 'ALSO Cloud %';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -94,3 +94,15 @@ def test_legacy_draft_id_is_saved_as_order_number():
|
|||||||
from app.modules.orders.backend.router import _economic_order_number
|
from app.modules.orders.backend.router import _economic_order_number
|
||||||
|
|
||||||
assert _economic_order_number({"economic_draft_id": 4711}) == 4711
|
assert _economic_order_number({"economic_draft_id": 4711}) == 4711
|
||||||
|
|
||||||
|
|
||||||
|
def test_exported_status_requires_economic_order_number():
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from app.modules.orders.backend.router import _validate_sync_status_number
|
||||||
|
|
||||||
|
try:
|
||||||
|
_validate_sync_status_number("exported", None, None)
|
||||||
|
assert False, "exported status without an external order number must be rejected"
|
||||||
|
except HTTPException as exc:
|
||||||
|
assert exc.status_code == 409
|
||||||
|
_validate_sync_status_number("exported", None, "4711")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user