Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee274a939a | ||
|
|
b95815de0d | ||
|
|
dbdb1c554a | ||
|
|
85fd99c5d8 | ||
|
|
4971f9721c | ||
|
|
a523bc4ac3 | ||
|
|
2a71e9285a |
11
MDfile/RELEASE_NOTES_v2.8.10.md
Normal file
11
MDfile/RELEASE_NOTES_v2.8.10.md
Normal file
@ -0,0 +1,11 @@
|
||||
# BMC Hub v2.8.10
|
||||
|
||||
## Gem e-conomic-ordrenummer
|
||||
|
||||
- Gemmer `economic_draft_id` fra det eksisterende eksportflow som kladdens e-conomic-ordrenummer.
|
||||
- Audit-eventet `export_success` indeholder dermed det faktiske nummer i stedet for `null`.
|
||||
- Bevarer understøttelse af de øvrige eksisterende feltnavne i eksportsvaret.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Regressionstest dækker mapping fra `economic_draft_id` til e-conomic-ordrenummer.
|
||||
6
MDfile/RELEASE_NOTES_v2.8.11.md
Normal file
6
MDfile/RELEASE_NOTES_v2.8.11.md
Normal file
@ -0,0 +1,6 @@
|
||||
# BMC Hub v2.8.11
|
||||
|
||||
## Ordreoverskrift
|
||||
|
||||
- Ordrekladder oprettet fra ALSO-abonnementer får overskriften `Abonnementer`.
|
||||
- Eksisterende åbne abonnementskladder får samme overskrift, når der tilføjes flere godkendte linjer.
|
||||
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
@ -0,0 +1,21 @@
|
||||
# BMC Hub v2.8.12
|
||||
|
||||
## Eksportkvittering og afstemning
|
||||
|
||||
- Gemmer e-conomic-kunde, valuta, layout, nettobeløb, request, response, ekstern reference, oprettede varer og brugeren, der eksporterede.
|
||||
- Sender den interne idempotency-reference med ordrekladden til e-conomic.
|
||||
- En ordre kan ikke længere markeres som eksporteret, bogført eller betalt uden et e-conomic-ordrenummer.
|
||||
- Eksisterende poster i den ugyldige kombination flyttes til `uncertain`, så de skal afstemmes før en eventuel ny eksport.
|
||||
- Gemmer og viser den seneste eksportfejl.
|
||||
|
||||
## Ordrevisning
|
||||
|
||||
- Viser kundenavn sammen med Hub-ID og e-conomic-kundenummer.
|
||||
- Viser en kompakt eksportkvittering med ordrenummer, periode, valuta, layout, nettobeløb, bruger og reference.
|
||||
- Viser eksporthistorik som læsbare beskrivelser i stedet for rå JSON.
|
||||
- Skjuler fakturanummer indtil bogføring og skjuler tvungen geneksport efter gennemført eksport.
|
||||
- Retter eksisterende ALSO-abonnementskladder til titlen `Abonnementer`.
|
||||
|
||||
## Installation
|
||||
|
||||
- Kør migration `245_ordre_export_receipt.sql` før applikationen opdateres.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.14.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.14.md
Normal file
@ -0,0 +1,13 @@
|
||||
# BMC Hub v2.8.14
|
||||
|
||||
## ALSO ordrekladder og e-conomic-reference
|
||||
|
||||
- Bruger Hub-ordre-ID som `Eksternt ID` ved eksport til e-conomic.
|
||||
- Efterlader `Tekst 1` tom for nye ALSO-genererede ordrekladder.
|
||||
- Tilføjer perioden fra CSV-feltet `Actual Charge Interval` til varelinjens beskrivelse.
|
||||
- Migration 247 rydder den tidligere systemtekst og opdaterer eksisterende, ikke-eksporterede ALSO-ordrekladder med perioden.
|
||||
- Bevarer den tidligere eksportreference ved afstemning af allerede påbegyndte eksportforsøg.
|
||||
|
||||
## Opgradering
|
||||
|
||||
Kør `migrations/247_also_order_external_id.sql` efter deployment. Migrationen ændrer kun ALSO-ordrekladder med status `pending`; eksporterede ordre ændres ikke.
|
||||
11
MDfile/RELEASE_NOTES_v2.8.15.md
Normal file
11
MDfile/RELEASE_NOTES_v2.8.15.md
Normal file
@ -0,0 +1,11 @@
|
||||
# BMC Hub v2.8.15
|
||||
|
||||
## ALSO-perioder på ordrelinjer
|
||||
|
||||
- Viser perioden fra `Actual Charge Interval` på en ny linje under produktnavnet.
|
||||
- Migration 248 retter eksisterende, ikke-eksporterede ALSO-ordrekladder.
|
||||
- Allerede eksporterede ordre ændres ikke.
|
||||
|
||||
## Opgradering
|
||||
|
||||
Kør `migrations/248_also_order_period_line_break.sql` efter deployment.
|
||||
9
MDfile/RELEASE_NOTES_v2.8.16.md
Normal file
9
MDfile/RELEASE_NOTES_v2.8.16.md
Normal file
@ -0,0 +1,9 @@
|
||||
# BMC Hub v2.8.16
|
||||
|
||||
## Flerlinjede ordrebeskrivelser
|
||||
|
||||
- Viser ordrelinjens produkt og ALSO-periode på separate linjer i både opret- og detaljevisningen.
|
||||
- Bruger et kompakt tekstområde ved redigering, så linjeskiftet bevares ved gem og eksport til e-conomic.
|
||||
- Viser linjeskift i forhåndsvisningen af en e-conomic-opdatering.
|
||||
|
||||
Der følger ingen ny databasemigration med denne release.
|
||||
@ -14,7 +14,9 @@ ALLOWED_WRITES = {
|
||||
}
|
||||
|
||||
_approved_four_eyes_write = ContextVar('approved_four_eyes_write', default=False)
|
||||
_approved_order_draft_update = ContextVar('approved_order_draft_update', default=False)
|
||||
_FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$')
|
||||
_ORDER_DRAFT_UPDATE = re.compile(r'^orders/drafts/[0-9]+$')
|
||||
|
||||
|
||||
@contextmanager
|
||||
@ -27,6 +29,16 @@ def approved_four_eyes_write():
|
||||
_approved_four_eyes_write.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def approved_order_draft_update():
|
||||
"""Permit one user-confirmed update of an existing e-conomic order draft."""
|
||||
token = _approved_order_draft_update.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_approved_order_draft_update.reset(token)
|
||||
|
||||
|
||||
def assert_economic_write_allowed(method: str, path: str) -> None:
|
||||
clean_path = path.split('?', 1)[0].strip('/')
|
||||
operation = (method.upper(), clean_path)
|
||||
@ -35,7 +47,12 @@ def assert_economic_write_allowed(method: str, path: str) -> None:
|
||||
and _FOUR_EYES_UPDATES.fullmatch(clean_path)
|
||||
and _approved_four_eyes_write.get()
|
||||
)
|
||||
if operation not in ALLOWED_WRITES and not approved_update:
|
||||
approved_draft_update = (
|
||||
operation[0] == 'PUT'
|
||||
and _ORDER_DRAFT_UPDATE.fullmatch(clean_path)
|
||||
and _approved_order_draft_update.get()
|
||||
)
|
||||
if operation not in ALLOWED_WRITES and not approved_update and not approved_draft_update:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder',
|
||||
|
||||
@ -275,6 +275,21 @@ def _extract_period_start(value: Any) -> Optional[str]:
|
||||
return _parse_date_candidate(match.group(1))
|
||||
|
||||
|
||||
def _format_charge_period(value: Any) -> Optional[str]:
|
||||
"""Return an explicit ALSO service period without inferring a missing end date."""
|
||||
text = _normalized_text(value)
|
||||
if not text:
|
||||
return None
|
||||
match = re.match(r"\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*-\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*$", text)
|
||||
if not match:
|
||||
return None
|
||||
start = _parse_date_candidate(match.group(1))
|
||||
end = _parse_date_candidate(match.group(2))
|
||||
if not start or not end:
|
||||
return None
|
||||
return f"{datetime.strptime(start, '%Y-%m-%d').strftime('%d.%m.%Y')} - {datetime.strptime(end, '%Y-%m-%d').strftime('%d.%m.%Y')}"
|
||||
|
||||
|
||||
def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
|
||||
product_name = _normalize_match_key(line.get("product_name"))
|
||||
if "microsoftorganizationtenant" not in product_name:
|
||||
@ -319,7 +334,8 @@ class AlsoService:
|
||||
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
|
||||
"currency": ["currency", "valuta"],
|
||||
"billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"],
|
||||
"charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
|
||||
# The CSV's Actual Charge Interval is the authoritative service period.
|
||||
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
|
||||
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"],
|
||||
"billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"],
|
||||
"source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
|
||||
@ -2008,6 +2024,10 @@ class AlsoService:
|
||||
quantity = Decimal("1")
|
||||
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
|
||||
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
|
||||
description = line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement"
|
||||
charge_period = _format_charge_period(line.get("charge_interval"))
|
||||
if charge_period:
|
||||
description = f"{description}\nPeriode: {charge_period}"
|
||||
|
||||
draft_lines.append(
|
||||
{
|
||||
@ -2015,7 +2035,7 @@ class AlsoService:
|
||||
"source_type": "also_cloud",
|
||||
"source_id": int(line["id"]),
|
||||
"reference_id": int(line["import_job_id"]),
|
||||
"description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
|
||||
"description": description,
|
||||
"quantity": float(quantity),
|
||||
"unit": "stk",
|
||||
"unit_price": float(unit_price),
|
||||
@ -2032,6 +2052,7 @@ class AlsoService:
|
||||
"also_material_number": line.get("material_number"),
|
||||
"also_vendor": line.get("vendor"),
|
||||
"also_import_job_id": int(line.get("import_job_id")),
|
||||
"billing_period": charge_period,
|
||||
},
|
||||
}
|
||||
)
|
||||
@ -2067,7 +2088,7 @@ class AlsoService:
|
||||
]
|
||||
execute_query(
|
||||
"""UPDATE ordre_drafts
|
||||
SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(_json_dumps(merged_lines), int(draft["id"])),
|
||||
)
|
||||
@ -2090,10 +2111,10 @@ class AlsoService:
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
f"ALSO Cloud {customer_name} - {period_key}",
|
||||
"Abonnementer",
|
||||
customer_id,
|
||||
_json_dumps(draft_lines),
|
||||
"Genereret fra ALSO Cloud Billing approval",
|
||||
None,
|
||||
1,
|
||||
approved_by_user_id,
|
||||
_json_dumps({"source": "also_cloud_billing"}),
|
||||
|
||||
@ -130,6 +130,8 @@ class OrdreEconomicExportService:
|
||||
document_key: Optional[str] = None,
|
||||
currency: str = 'DKK',
|
||||
create_missing_products: Optional[Dict[str, int]] = None,
|
||||
export_reference: Optional[str] = None,
|
||||
external_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
|
||||
connection = active_connection()
|
||||
@ -138,7 +140,8 @@ class OrdreEconomicExportService:
|
||||
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_id, lines, layout_number, currency, notes, user_id,
|
||||
external_id=external_id)
|
||||
customer = execute_query_single(
|
||||
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
@ -178,6 +181,7 @@ class OrdreEconomicExportService:
|
||||
|
||||
economic_lines: List[Dict[str, Any]] = []
|
||||
creation_candidates: Dict[str, Dict[str, Any]] = {}
|
||||
created_product_numbers: List[str] = []
|
||||
for line in selected_lines:
|
||||
try:
|
||||
quantity = float(line.get("quantity") or 0)
|
||||
@ -321,6 +325,7 @@ class OrdreEconomicExportService:
|
||||
if str(created.get("productNumber") or "") != number:
|
||||
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
|
||||
valid_product_numbers.add(number)
|
||||
created_product_numbers.append(number)
|
||||
_require_valid_product_numbers(product_numbers, valid_product_numbers)
|
||||
|
||||
customer_layout = economic_customer.get("layout") or {}
|
||||
@ -347,6 +352,10 @@ class OrdreEconomicExportService:
|
||||
|
||||
if notes:
|
||||
payload["notes"] = {"textLine1": str(notes)[:1000]}
|
||||
if external_id is not None:
|
||||
payload["references"] = {"other": str(external_id)[:100]}
|
||||
elif export_reference:
|
||||
payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]}
|
||||
|
||||
if not write_allowed:
|
||||
return {
|
||||
@ -400,6 +409,12 @@ class OrdreEconomicExportService:
|
||||
"skipped_product_numbers": missing_product_numbers,
|
||||
"missing_product_numbers": missing_product_numbers,
|
||||
"economic_response": export_result,
|
||||
"request_payload": payload,
|
||||
"economic_customer_number": customer_number,
|
||||
"currency": payload["currency"],
|
||||
"layout_number": int(resolved_layout_number),
|
||||
"economic_order_url": export_result.get("self"),
|
||||
"created_product_numbers": created_product_numbers,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
182
app/modules/orders/backend/economic_sync.py
Normal file
182
app/modules/orders/backend/economic_sync.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""Safe updates and daily read-only lifecycle checks for e-conomic order drafts."""
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from app.core.economic_write_policy import approved_order_draft_update
|
||||
from app.products.backend.economic_catalog import EconomicClient, RemoteError, query, transaction
|
||||
from app.products.backend.economic_documents import active_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _number(value):
|
||||
try:
|
||||
return str(Decimal(str(value or 0)).quantize(Decimal('.01')))
|
||||
except Exception:
|
||||
return '0.00'
|
||||
|
||||
|
||||
def _editable_snapshot(payload):
|
||||
return {
|
||||
'notes': ((payload.get('notes') or {}).get('textLine1') or ''),
|
||||
'lines': [{
|
||||
'product_number': ((line.get('product') or {}).get('productNumber')),
|
||||
'description': line.get('description') or '',
|
||||
'quantity': _number(line.get('quantity')),
|
||||
'unit_price': _number(line.get('unitNetPrice')),
|
||||
'discount_percentage': _number(line.get('discountPercentage')),
|
||||
} for line in payload.get('lines') or []],
|
||||
}
|
||||
|
||||
|
||||
def build_update_payload(remote, local_lines, notes):
|
||||
"""Keep external identity fields and only replace user-editable order content."""
|
||||
allowed = ('date', 'currency', 'customer', 'paymentTerms', 'recipient', 'layout', 'references')
|
||||
payload = {key: deepcopy(remote[key]) for key in allowed if key in remote}
|
||||
remote_lines = remote.get('lines') or []
|
||||
if len(remote_lines) != len(local_lines):
|
||||
raise HTTPException(409, 'Antallet af linjer er ændret. Opret en ny ordre for at tilføje eller fjerne varer.')
|
||||
changed, payload_lines = [], []
|
||||
for remote_line, local in zip(remote_lines, local_lines):
|
||||
if not (remote_line.get('product') or {}).get('productNumber'):
|
||||
raise HTTPException(409, 'En eksisterende e-conomic-linje mangler varenummer og kan ikke opdateres sikkert.')
|
||||
output = {'product': deepcopy(remote_line['product']),
|
||||
'description': str(local.get('description') or 'Ordrelinje'),
|
||||
'quantity': float(local.get('quantity') or 0),
|
||||
'unitNetPrice': float(local.get('unit_price') or 0),
|
||||
'discountPercentage': float(local.get('discount_percentage') or 0)}
|
||||
if remote_line.get('unit'):
|
||||
output['unit'] = deepcopy(remote_line['unit'])
|
||||
if output['quantity'] <= 0 or output['unitNetPrice'] < 0 or not 0 <= output['discountPercentage'] <= 100:
|
||||
raise HTTPException(422, 'Ugyldig mængde, pris eller rabat')
|
||||
payload_lines.append(output)
|
||||
changed.append(dict(local))
|
||||
payload['lines'] = payload_lines
|
||||
if notes:
|
||||
payload['notes'] = {'textLine1': str(notes)[:1000]}
|
||||
else:
|
||||
payload.pop('notes', None)
|
||||
return payload, changed
|
||||
|
||||
|
||||
async def create_update_preview(draft_id, local_lines, notes, actor_id):
|
||||
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
|
||||
if not row:
|
||||
raise HTTPException(404, 'Ordren findes ikke')
|
||||
if not row.get('economic_order_number') or row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
|
||||
raise HTTPException(409, 'Kun en åben e-conomic-ordrekladde kan opdateres')
|
||||
connection = active_connection()
|
||||
client = EconomicClient()
|
||||
if connection:
|
||||
await client.verify(connection['id'])
|
||||
try:
|
||||
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
|
||||
except RemoteError as exc:
|
||||
if exc.status == 404:
|
||||
raise HTTPException(409, 'Ordren er ikke længere en åben kladde. Hent status for at finde fakturaen.') from exc
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
proposed, saved_lines = build_update_payload(remote, local_lines, notes)
|
||||
before, after = _editable_snapshot(remote), _editable_snapshot(proposed)
|
||||
with transaction() as cur:
|
||||
cur.execute("UPDATE economic_order_update_requests SET status='expired' WHERE draft_id=%s AND status='pending'", (draft_id,))
|
||||
cur.execute('''INSERT INTO economic_order_update_requests
|
||||
(draft_id,economic_order_number,before_snapshot,proposed_payload,proposed_local_lines,requested_by_user_id)
|
||||
VALUES(%s,%s,%s,%s,%s,%s) RETURNING id,expires_at''',
|
||||
(draft_id, str(row['economic_order_number']), Json(before), Json(proposed), Json(saved_lines), actor_id))
|
||||
request_row = cur.fetchone()
|
||||
return {'request_id': str(request_row['id']), 'expires_at': request_row['expires_at'], 'before': before, 'after': after}
|
||||
|
||||
|
||||
async def apply_update(request_id, draft_id, actor_id):
|
||||
try:
|
||||
UUID(str(request_id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, 'Ugyldig godkendelsesreference') from exc
|
||||
row = query('''SELECT r.*,d.sync_status,d.economic_invoice_number FROM economic_order_update_requests r
|
||||
JOIN ordre_drafts d ON d.id=r.draft_id WHERE r.id=%s AND r.draft_id=%s''', (request_id, draft_id), one=True)
|
||||
if not row or row['status'] != 'pending' or row['expires_at'] <= datetime.now(timezone.utc):
|
||||
raise HTTPException(409, 'Godkendelsen er udløbet eller allerede brugt')
|
||||
if row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
|
||||
raise HTTPException(409, 'Ordren er faktureret og kan ikke længere opdateres')
|
||||
client = EconomicClient()
|
||||
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
|
||||
if _editable_snapshot(remote) != row['before_snapshot']:
|
||||
raise HTTPException(409, 'Ordren er ændret i e-conomic siden forhåndsvisningen. Lav en ny forhåndsvisning.')
|
||||
try:
|
||||
with approved_order_draft_update():
|
||||
response = await client.request('PUT', f"orders/drafts/{int(row['economic_order_number'])}", row['proposed_payload'])
|
||||
except RemoteError as exc:
|
||||
query("UPDATE economic_order_update_requests SET status='failed',error=%s WHERE id=%s", (str(exc), request_id))
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
with transaction() as cur:
|
||||
cur.execute("SELECT set_config('bmc.economic_order_update',%s,true)", (str(draft_id),))
|
||||
cur.execute('''UPDATE ordre_drafts SET lines_json=%s,notes=%s,export_request_json=%s,
|
||||
export_response_json=%s,last_sync_at=now(),updated_at=now() WHERE id=%s''',
|
||||
(Json(row['proposed_local_lines']), (row['proposed_payload'].get('notes') or {}).get('textLine1'),
|
||||
Json(row['proposed_payload']), Json(response), draft_id))
|
||||
cur.execute("UPDATE economic_order_update_requests SET status='applied',applied_at=now(),response=%s WHERE id=%s", (Json(response), request_id))
|
||||
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload,created_by_user_id)
|
||||
VALUES(%s,'economic_draft_updated',%s,%s,%s,%s)''',
|
||||
(draft_id, row['sync_status'], row['sync_status'], Json({'request_id': str(request_id)}), actor_id))
|
||||
return {'status': 'updated', 'economic_order_number': row['economic_order_number']}
|
||||
|
||||
|
||||
async def sync_one_draft(draft_id, force=False):
|
||||
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
|
||||
if not row or not row.get('economic_order_number'):
|
||||
raise HTTPException(404, 'Ordren har intet e-conomic-ordrenummer')
|
||||
if not force and row.get('economic_last_checked_at') and row['economic_last_checked_at'] > datetime.now(timezone.utc) - timedelta(days=1):
|
||||
return {'status': row.get('economic_document_state') or 'unchanged', 'skipped': True}
|
||||
client = EconomicClient()
|
||||
marker = 'BMC-HUB:' + str(row.get('economic_export_id') or row.get('export_idempotency_key') or '')
|
||||
state, invoice, remote = 'draft', None, None
|
||||
try:
|
||||
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
|
||||
except RemoteError as exc:
|
||||
if exc.status != 404:
|
||||
query('UPDATE ordre_drafts SET economic_last_checked_at=now(),economic_status_error=%s WHERE id=%s', (str(exc), draft_id))
|
||||
raise
|
||||
booked = await client.collection('invoices/booked')
|
||||
order_number = str(row['economic_order_number'])
|
||||
matches = [x for x in booked if (
|
||||
(x.get('references') or {}).get('other') == marker
|
||||
or str(x.get('orderNumber') or (x.get('references') or {}).get('orderNumber') or '') == order_number
|
||||
)]
|
||||
if len(matches) == 1:
|
||||
remote, invoice, state = matches[0], str(matches[0].get('bookedInvoiceNumber') or matches[0].get('invoiceNumber')), 'posted'
|
||||
paid = await client.collection('invoices/paid')
|
||||
if any(str(x.get('bookedInvoiceNumber') or x.get('invoiceNumber')) == invoice for x in paid):
|
||||
state = 'paid'
|
||||
else:
|
||||
state = 'not_found'
|
||||
payment = 'paid' if state == 'paid' else ('unpaid' if state == 'posted' else None)
|
||||
with transaction() as cur:
|
||||
cur.execute('''UPDATE ordre_drafts SET economic_invoice_number=COALESCE(%s,economic_invoice_number),
|
||||
sync_status=CASE WHEN %s='paid' THEN 'paid' WHEN %s='posted' THEN 'posted' ELSE sync_status END,
|
||||
economic_document_state=%s,economic_payment_status=%s,economic_status_response=%s,
|
||||
economic_status_error=NULL,economic_last_checked_at=now(),last_sync_at=now(),updated_at=now() WHERE id=%s''',
|
||||
(invoice,state,state,state,payment,Json(remote or {}),draft_id))
|
||||
if state in ('posted','paid') and state != row.get('sync_status'):
|
||||
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload)
|
||||
VALUES(%s,'economic_status_sync',%s,%s,%s)''', (draft_id,row.get('sync_status'),state,Json({'invoice_number':invoice,'payment_status':payment})))
|
||||
return {'status': state, 'invoice_number': invoice, 'payment_status': payment, 'skipped': False}
|
||||
|
||||
|
||||
async def sync_due_drafts():
|
||||
rows = query("""SELECT id FROM ordre_drafts WHERE economic_order_number IS NOT NULL
|
||||
AND sync_status IN ('exported','posted')
|
||||
AND (economic_last_checked_at IS NULL OR economic_last_checked_at < now()-interval '1 day')
|
||||
ORDER BY economic_last_checked_at NULLS FIRST LIMIT 100""") or []
|
||||
for row in rows:
|
||||
try:
|
||||
await sync_one_draft(row['id'])
|
||||
except Exception:
|
||||
logger.exception('Daglig e-conomic-statuskontrol fejlede for ordre %s', row['id'])
|
||||
return {'checked': len(rows)}
|
||||
@ -12,7 +12,23 @@ from app.modules.orders.backend.service import aggregate_order_lines
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
||||
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"}
|
||||
|
||||
|
||||
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
|
||||
"""Normalize legacy and catalog-aware e-conomic export response fields."""
|
||||
return (
|
||||
export_result.get("economic_order_number")
|
||||
or export_result.get("economic_draft_id")
|
||||
or export_result.get("draftOrderNumber")
|
||||
or export_result.get("order_number")
|
||||
or export_result.get("orderNumber")
|
||||
)
|
||||
|
||||
|
||||
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
|
||||
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
|
||||
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
|
||||
|
||||
|
||||
def _snapshot_lines(lines):
|
||||
@ -72,6 +88,15 @@ class OrdreDraftConsolidateRequest(BaseModel):
|
||||
draft_ids: List[int] = Field(..., min_length=2)
|
||||
|
||||
|
||||
class EconomicDraftUpdatePreviewRequest(BaseModel):
|
||||
lines: List[Dict[str, Any]]
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class EconomicDraftUpdateConfirmRequest(BaseModel):
|
||||
request_id: str
|
||||
|
||||
|
||||
def _safe_json_field(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
@ -216,6 +241,8 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
|
||||
currency=request.currency,
|
||||
create_missing_products=request.create_missing_products,
|
||||
export_reference=export_idempotency_key,
|
||||
external_id=request.draft_id,
|
||||
)
|
||||
|
||||
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
|
||||
@ -236,16 +263,22 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
economic_order_number = (
|
||||
export_result.get("economic_order_number")
|
||||
or export_result.get("order_number")
|
||||
or export_result.get("orderNumber")
|
||||
)
|
||||
economic_order_number = _economic_order_number(export_result)
|
||||
economic_invoice_number = (
|
||||
export_result.get("economic_invoice_number")
|
||||
or export_result.get("invoice_number")
|
||||
or export_result.get("invoiceNumber")
|
||||
)
|
||||
if not export_result.get("dry_run") and not economic_order_number:
|
||||
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
|
||||
details = export_result.get("details") or {}
|
||||
request_payload = details.get("request_payload") or {}
|
||||
response_payload = details.get("economic_response") or {}
|
||||
net_amount = sum(
|
||||
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
|
||||
* (1 - float(line.get("discountPercentage") or 0) / 100)
|
||||
for line in (request_payload.get("lines") or [])
|
||||
)
|
||||
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
|
||||
|
||||
execute_query(
|
||||
@ -256,6 +289,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
export_idempotency_key = %s,
|
||||
economic_order_number = COALESCE(%s, economic_order_number),
|
||||
economic_invoice_number = COALESCE(%s, economic_invoice_number),
|
||||
economic_export_id = %s,
|
||||
economic_order_url = %s,
|
||||
exported_economic_customer_number = %s,
|
||||
exported_currency = %s,
|
||||
exported_layout_number = %s,
|
||||
exported_net_amount = %s,
|
||||
export_request_json = %s::jsonb,
|
||||
export_response_json = %s::jsonb,
|
||||
exported_created_products = %s::jsonb,
|
||||
last_export_error = NULL,
|
||||
exported_by_user_id = %s,
|
||||
last_sync_at = CURRENT_TIMESTAMP,
|
||||
last_exported_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
@ -267,6 +311,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
export_idempotency_key,
|
||||
str(economic_order_number) if economic_order_number is not None else None,
|
||||
str(economic_invoice_number) if economic_invoice_number is not None else None,
|
||||
export_result.get("export_id") or export_idempotency_key,
|
||||
details.get("economic_order_url"),
|
||||
str(details.get("economic_customer_number") or "") or None,
|
||||
details.get("currency"),
|
||||
details.get("layout_number"),
|
||||
net_amount,
|
||||
json.dumps(request_payload, ensure_ascii=False),
|
||||
json.dumps(response_payload, ensure_ascii=False),
|
||||
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
|
||||
user_id,
|
||||
request.draft_id,
|
||||
),
|
||||
)
|
||||
@ -281,12 +335,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"idempotency_key": export_idempotency_key,
|
||||
"economic_order_number": economic_order_number,
|
||||
"economic_invoice_number": economic_invoice_number,
|
||||
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
|
||||
"economic_customer_number": details.get("economic_customer_number"),
|
||||
"currency": details.get("currency"),
|
||||
"layout_number": details.get("layout_number"),
|
||||
"net_amount": net_amount,
|
||||
"created_product_numbers": details.get("created_product_numbers") or [],
|
||||
},
|
||||
user_id,
|
||||
)
|
||||
|
||||
return export_result
|
||||
except HTTPException:
|
||||
except HTTPException as exc:
|
||||
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
|
||||
if request.draft_id and not is_preview:
|
||||
try:
|
||||
from app.core.database import execute_query
|
||||
execute_query(
|
||||
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
||||
(str(exc.detail)[:2000], request.draft_id),
|
||||
)
|
||||
_log_sync_event(
|
||||
request.draft_id, "export_failed", previous_status, previous_status,
|
||||
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
|
||||
"idempotency_key": export_idempotency_key}, user_id,
|
||||
)
|
||||
except Exception as log_exc:
|
||||
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
|
||||
@ -337,7 +412,13 @@ async def list_ordre_drafts(
|
||||
async def get_ordre_draft(draft_id: int, http_request: Request):
|
||||
"""Get single ordre draft with lines payload (no user filtering)."""
|
||||
try:
|
||||
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
|
||||
query = """SELECT d.*, c.name AS customer_name,
|
||||
c.economic_customer_number AS current_economic_customer_number,
|
||||
u.full_name AS exported_by_name
|
||||
FROM ordre_drafts d
|
||||
LEFT JOIN customers c ON c.id = d.customer_id
|
||||
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
|
||||
WHERE d.id = %s LIMIT 1"""
|
||||
params = (draft_id,)
|
||||
|
||||
from app.core.database import execute_query_single
|
||||
@ -355,6 +436,36 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
|
||||
|
||||
|
||||
async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]:
|
||||
from app.core.auth_dependencies import get_current_user, security
|
||||
from app.core.auth_service import AuthService
|
||||
actor = await get_current_user(http_request, await security(http_request))
|
||||
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.orders.update'):
|
||||
raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder')
|
||||
return actor
|
||||
|
||||
|
||||
@router.post('/ordre/drafts/{draft_id}/economic-update-preview')
|
||||
async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request):
|
||||
actor = await _require_economic_order_update(http_request)
|
||||
from app.modules.orders.backend.economic_sync import create_update_preview
|
||||
return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id'])
|
||||
|
||||
|
||||
@router.post('/ordre/drafts/{draft_id}/economic-update-confirm')
|
||||
async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request):
|
||||
actor = await _require_economic_order_update(http_request)
|
||||
from app.modules.orders.backend.economic_sync import apply_update
|
||||
return await apply_update(payload.request_id, draft_id, actor['id'])
|
||||
|
||||
|
||||
@router.post('/ordre/drafts/{draft_id}/economic-status-refresh')
|
||||
async def refresh_economic_draft_status(draft_id: int, http_request: Request):
|
||||
await _require_economic_order_update(http_request)
|
||||
from app.modules.orders.backend.economic_sync import sync_one_draft
|
||||
return await sync_one_draft(draft_id, force=True)
|
||||
|
||||
|
||||
@router.post("/ordre/drafts")
|
||||
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
|
||||
"""Create a new ordre draft."""
|
||||
@ -404,6 +515,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
|
||||
@ -413,6 +525,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
|
||||
"""
|
||||
return execute_query_single(query, ()) or {
|
||||
"pending_count": 0,
|
||||
"uncertain_count": 0,
|
||||
"exported_count": 0,
|
||||
"failed_count": 0,
|
||||
"posted_count": 0,
|
||||
@ -457,12 +570,13 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
|
||||
|
||||
from app.core.database import execute_query_single
|
||||
previous = execute_query_single(
|
||||
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
|
||||
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s",
|
||||
(draft_id,)
|
||||
)
|
||||
if not previous:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
from_status = (previous.get("sync_status") or "pending").strip().lower()
|
||||
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
|
||||
|
||||
values.append(draft_id)
|
||||
from app.core.database import execute_query
|
||||
@ -565,6 +679,19 @@ async def list_ordre_draft_sync_events(
|
||||
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
|
||||
"""Update existing ordre draft."""
|
||||
try:
|
||||
from app.core.database import execute_query_single
|
||||
existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,))
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}:
|
||||
content_changed = (
|
||||
existing.get('customer_id') != request.customer_id
|
||||
or (_safe_json_field(existing.get('lines_json')) or []) != request.lines
|
||||
or (existing.get('notes') or None) != (request.notes or None)
|
||||
or existing.get('layout_number') != request.layout_number
|
||||
)
|
||||
if content_changed:
|
||||
raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.')
|
||||
await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
|
||||
from app.core.database import execute_query
|
||||
|
||||
|
||||
@ -344,9 +344,9 @@
|
||||
const index = line.originalIndex;
|
||||
const isManual = line.source_type === 'manual';
|
||||
const descriptionField = isManual
|
||||
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
onchange="ordreLines[${index}].description = this.value;">`
|
||||
: escapeHtml(line.description || '-');
|
||||
? `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
|
||||
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
|
||||
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
|
||||
|
||||
const manualActions = isManual
|
||||
? `
|
||||
|
||||
@ -97,8 +97,9 @@
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
||||
</div>
|
||||
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="forceExportToggle">
|
||||
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
|
||||
@ -117,6 +118,7 @@
|
||||
<div class="info-item">
|
||||
<div class="info-label">Kunde ID</div>
|
||||
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
|
||||
<div id="customerIdentity" class="small text-muted mt-1">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@ -147,13 +149,37 @@
|
||||
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
|
||||
</div>
|
||||
|
||||
<div id="exportReceiptCard" class="card mb-3 d-none">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
|
||||
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
|
||||
</div>
|
||||
<div class="card-body"><div class="row g-3">
|
||||
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
|
||||
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
|
||||
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
|
||||
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
|
||||
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
|
||||
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
|
||||
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
|
||||
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
|
||||
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sync-card">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
|
||||
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
|
||||
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
|
||||
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
|
||||
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
|
||||
</button>
|
||||
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
|
||||
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
|
||||
</button>
|
||||
@ -168,6 +194,7 @@
|
||||
<div class="sync-label">Sync status</div>
|
||||
<select id="syncStatusSelect" class="form-select form-select-sm">
|
||||
<option value="pending">pending</option>
|
||||
<option value="uncertain">uncertain</option>
|
||||
<option value="exported">exported</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="posted">posted</option>
|
||||
@ -178,7 +205,7 @@
|
||||
<div class="sync-label">e-conomic ordre nr.</div>
|
||||
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-3" id="invoiceNumberEditWrap">
|
||||
<div class="sync-label">e-conomic faktura nr.</div>
|
||||
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
|
||||
</div>
|
||||
@ -202,15 +229,19 @@
|
||||
<div class="sync-label">Ordrenummer</div>
|
||||
<div id="economicOrderNumberView" class="sync-value">-</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-3" id="invoiceNumberViewWrap">
|
||||
<div class="sync-label">Fakturanummer</div>
|
||||
<div id="economicInvoiceNumberView" class="sync-value">-</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="sync-label">Betalingsstatus</div>
|
||||
<div id="economicPaymentStatusView" class="sync-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label mb-1">Event type</label>
|
||||
<label class="form-label mb-1">Hændelse</label>
|
||||
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
@ -318,6 +349,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="economicUpdatePreviewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
|
||||
<div class="modal-header"><div><h5 class="modal-title">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
|
||||
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -331,6 +369,7 @@
|
||||
let syncEventsTotal = 0;
|
||||
let detailToast = null;
|
||||
let pendingEconomicProductPreview = null;
|
||||
let pendingEconomicUpdateId = null;
|
||||
|
||||
function showToast(message, variant = 'dark') {
|
||||
const toastEl = document.getElementById('detailToast');
|
||||
@ -349,8 +388,8 @@
|
||||
detailToast.show();
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
||||
function formatCurrency(value, currency = 'DKK') {
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
@ -381,6 +420,7 @@
|
||||
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
|
||||
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
|
||||
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
|
||||
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
|
||||
return '<span class="badge bg-warning text-dark">pending</span>';
|
||||
}
|
||||
|
||||
@ -393,6 +433,32 @@
|
||||
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
|
||||
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
|
||||
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
|
||||
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
|
||||
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
|
||||
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
|
||||
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
|
||||
|
||||
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
|
||||
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
|
||||
if (hasReceipt) {
|
||||
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
|
||||
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
|
||||
const period = orderData.coverage_start && orderData.coverage_end
|
||||
? `${orderData.coverage_start} – ${orderData.coverage_end}`
|
||||
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
|
||||
document.getElementById('receiptPeriod').textContent = period;
|
||||
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
|
||||
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
|
||||
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
|
||||
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
|
||||
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
|
||||
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
|
||||
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
|
||||
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
|
||||
const link = document.getElementById('economicOrderLink');
|
||||
link.classList.toggle('d-none', !orderData.economic_order_url);
|
||||
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLines() {
|
||||
@ -405,9 +471,11 @@
|
||||
|
||||
tbody.innerHTML = orderLines.map((line, index) => {
|
||||
const isExportedLine = line.export_status === 'exported';
|
||||
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].description = this.value;">`;
|
||||
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
|
||||
const lockLine = isExportedLine && !mayEditExportedDraft;
|
||||
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
|
||||
|
||||
const exportStatus = line.export_status || '-';
|
||||
const statusBadge = exportStatus === 'exported'
|
||||
@ -433,17 +501,17 @@
|
||||
<td>${descriptionField}</td>
|
||||
<td style="min-width:100px;">
|
||||
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:120px;">
|
||||
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:110px;">
|
||||
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||
@ -597,13 +665,25 @@
|
||||
|
||||
document.getElementById('orderTitle').value = orderData.title || '';
|
||||
document.getElementById('customerId').value = orderData.customer_id || '';
|
||||
document.getElementById('customerIdentity').textContent = orderData.customer_name
|
||||
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
|
||||
: '-';
|
||||
document.getElementById('layoutNumber').value = orderData.layout_number || '';
|
||||
document.getElementById('orderNotes').value = orderData.notes || '';
|
||||
|
||||
const hasExported = orderData.last_exported_at ? true : false;
|
||||
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
|
||||
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
|
||||
document.getElementById('orderStatus').innerHTML = hasExported
|
||||
? '<span class="badge bg-success">Eksporteret</span>'
|
||||
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
|
||||
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>'
|
||||
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||
const errorBanner = document.getElementById('exportErrorBanner');
|
||||
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
|
||||
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
|
||||
const openEconomicDraft = orderData.sync_status === 'exported' && orderData.economic_order_number && !orderData.economic_invoice_number;
|
||||
document.getElementById('economicUpdateBtn').classList.toggle('d-none', !openEconomicDraft);
|
||||
document.getElementById('economicStatusBtn').classList.toggle('d-none', !orderData.economic_order_number);
|
||||
document.getElementById('economicPaymentStatusView').textContent = orderData.economic_payment_status || (orderData.economic_document_state === 'draft' ? 'Ikke faktureret' : '-');
|
||||
|
||||
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
|
||||
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
||||
@ -694,9 +774,18 @@
|
||||
}
|
||||
|
||||
body.innerHTML = items.map((event) => {
|
||||
const payload = typeof event.event_payload === 'object'
|
||||
? JSON.stringify(event.event_payload, null, 2)
|
||||
: String(event.event_payload || '');
|
||||
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
|
||||
let payload = String(event.event_payload || '');
|
||||
if (event.event_type === 'export_success') {
|
||||
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
|
||||
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
|
||||
} else if (event.event_type === 'export_attempt') {
|
||||
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
|
||||
} else if (event.event_type === 'sync_status_manual_update') {
|
||||
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
|
||||
} else if (typeof event.event_payload === 'object') {
|
||||
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
@ -704,7 +793,7 @@
|
||||
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
|
||||
<td>${escapeHtml(event.from_status || '-')}</td>
|
||||
<td>${escapeHtml(event.to_status || '-')}</td>
|
||||
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
|
||||
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@ -789,6 +878,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function previewEconomicUpdate() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-preview`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({lines: orderLines, notes: document.getElementById('orderNotes').value || null}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Forhåndsvisning fejlede');
|
||||
pendingEconomicUpdateId = data.request_id;
|
||||
const before = data.before || {}, after = data.after || {};
|
||||
const rows = (after.lines || []).map((line, i) => {
|
||||
const old = (before.lines || [])[i] || {};
|
||||
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td style="white-space:pre-line">${escapeHtml(old.description)} → ${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)} → ${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)} → ${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)} → ${escapeHtml(line.discount_percentage)}</td></tr>`;
|
||||
}).join('');
|
||||
document.getElementById('economicUpdateComparison').innerHTML = `<div class="mb-3"><strong>Noter:</strong> ${escapeHtml(before.notes || '-')} → ${escapeHtml(after.notes || '-')}</div><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Vare</th><th>Beskrivelse</th><th>Antal</th><th>Pris</th><th>Rabat %</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicUpdatePreviewModal')).show();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function confirmEconomicUpdate() {
|
||||
if (!pendingEconomicUpdateId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-confirm`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:pendingEconomicUpdateId})});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Opdateringen fejlede');
|
||||
bootstrap.Modal.getInstance(document.getElementById('economicUpdatePreviewModal'))?.hide();
|
||||
pendingEconomicUpdateId = null;
|
||||
showToast('e-conomic-ordrekladden er opdateret', 'success');
|
||||
await loadOrder();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function refreshEconomicStatus() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-status-refresh`, {method:'POST'});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Statuskontrol fejlede');
|
||||
showToast(data.invoice_number ? `Faktura ${data.invoice_number}: ${data.payment_status || data.status}` : 'Ordren er stadig en åben kladde', 'success');
|
||||
await loadOrder();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function previewPrices() {
|
||||
try {
|
||||
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;
|
||||
|
||||
@ -28,6 +28,11 @@ def amount(value):
|
||||
return decimal(value).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def external_reference(export_row_id, external_id=None):
|
||||
"""Use the saved Hub order id in e-conomic, with the legacy marker as fallback."""
|
||||
return str(external_id)[:100] if external_id is not None else 'BMC-HUB:' + str(export_row_id)
|
||||
|
||||
|
||||
def line_snapshot(line, product, connection_id, currency):
|
||||
if not product or not product.get('economic_product_number') or product.get('economic_connection_id') != connection_id:
|
||||
raise HTTPException(409, 'Varen mangler en verificeret kobling. Åbn Varer og e-conomic for at knytte eller oprette varen.')
|
||||
@ -115,8 +120,16 @@ async def preflight(connection, customer_id, lines, layout_number=None, currency
|
||||
|
||||
def result_for(row):
|
||||
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
|
||||
snapshot = row.get('snapshot') or {}
|
||||
payload = snapshot.get('payload') or {}
|
||||
response = row.get('response') or {}
|
||||
return {'success': True, 'dry_run': False, key: row['economic_number'],
|
||||
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']),
|
||||
'details': {'request_payload': payload, 'economic_response': response,
|
||||
'economic_customer_number': (payload.get('customer') or {}).get('customerNumber'),
|
||||
'currency': payload.get('currency'),
|
||||
'layout_number': (payload.get('layout') or {}).get('layoutNumber'),
|
||||
'economic_order_url': response.get('self'), 'created_product_numbers': []},
|
||||
'message': 'Eksporteret til e-conomic-kladde'}
|
||||
|
||||
|
||||
@ -131,7 +144,7 @@ def store_success(row, response, number):
|
||||
|
||||
|
||||
async def export_document(connection, kind, document_key, customer_id, lines, layout_number=None,
|
||||
currency='DKK', notes=None, user_id=None, module='orders'):
|
||||
currency='DKK', notes=None, user_id=None, module='orders', external_id=None):
|
||||
if kind != 'order':
|
||||
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
|
||||
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
|
||||
@ -157,7 +170,7 @@ async def export_document(connection, kind, document_key, customer_id, lines, la
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(409, 'Eksporten behandles allerede')
|
||||
marker = 'BMC-HUB:' + str(row['id'])
|
||||
marker = external_reference(row['id'], external_id)
|
||||
payload = snapshot['payload']
|
||||
payload['references'] = {'other': marker}
|
||||
snapshot['payload'] = payload
|
||||
@ -191,7 +204,8 @@ async def reconcile(export_id):
|
||||
if row['kind'] != 'order':
|
||||
raise HTTPException(403, 'Hub må kun afstemme ordrekladder')
|
||||
path = 'orders/drafts'
|
||||
marker = 'BMC-HUB:' + str(row['id'])
|
||||
expected = row['snapshot']['payload']
|
||||
marker = (expected.get('references') or {}).get('other') or external_reference(row['id'])
|
||||
matches = [r for r in await client.collection(path) if (r.get('references') or {}).get('other') == marker]
|
||||
if len(matches) != 1:
|
||||
raise HTTPException(409, f'Fandt {len(matches)} kladder med den præcise Hub-reference. Ingen ny eksport tilladt; kontrollér også bogførte/slettede dokumenter i e-conomic.')
|
||||
@ -200,7 +214,6 @@ async def reconcile(export_id):
|
||||
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', [])
|
||||
|
||||
11
main.py
11
main.py
@ -227,6 +227,17 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
logger.info("✅ Ordre draft reconcile job scheduled (daily at 04:30)")
|
||||
|
||||
from app.modules.orders.backend.economic_sync import sync_due_drafts
|
||||
backup_scheduler.scheduler.add_job(
|
||||
func=sync_due_drafts,
|
||||
trigger=CronTrigger(hour=4, minute=45),
|
||||
id='economic_order_daily_status',
|
||||
name='Daily e-conomic order and payment status',
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("✅ e-conomic ordre- og betalingsstatus planlagt dagligt kl. 04:45")
|
||||
|
||||
if settings.ESET_ENABLED and settings.ESET_SYNC_ENABLED:
|
||||
from app.jobs.eset_sync import run_eset_sync
|
||||
|
||||
|
||||
32
migrations/245_ordre_export_receipt.sql
Normal file
32
migrations/245_ordre_export_receipt.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- Persist the evidence needed to reconcile an order draft with e-conomic.
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE ordre_drafts
|
||||
ADD COLUMN IF NOT EXISTS economic_export_id VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS economic_order_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS exported_economic_customer_number VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS exported_currency VARCHAR(3),
|
||||
ADD COLUMN IF NOT EXISTS exported_layout_number INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS exported_net_amount NUMERIC(18,2),
|
||||
ADD COLUMN IF NOT EXISTS export_request_json JSONB,
|
||||
ADD COLUMN IF NOT EXISTS export_response_json JSONB,
|
||||
ADD COLUMN IF NOT EXISTS exported_created_products JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS last_export_error TEXT,
|
||||
ADD COLUMN IF NOT EXISTS exported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ordre_drafts DROP CONSTRAINT IF EXISTS ordre_drafts_sync_status_check;
|
||||
ALTER TABLE ordre_drafts ADD CONSTRAINT ordre_drafts_sync_status_check
|
||||
CHECK (sync_status IN ('pending', 'uncertain', 'exported', 'failed', 'posted', 'paid'));
|
||||
|
||||
UPDATE ordre_drafts
|
||||
SET sync_status = 'uncertain',
|
||||
last_export_error = COALESCE(last_export_error, 'Tidligere eksport: eksport markeret gennemført uden gemt e-conomic-ordrenummer; afstem manuelt før ny eksport')
|
||||
WHERE sync_status IN ('exported', 'posted', 'paid')
|
||||
AND economic_order_number IS NULL;
|
||||
|
||||
UPDATE ordre_drafts
|
||||
SET title = 'Abonnementer'
|
||||
WHERE invoice_aggregate_key LIKE 'also-cloud-%'
|
||||
AND title LIKE 'ALSO Cloud %';
|
||||
|
||||
COMMIT;
|
||||
48
migrations/246_economic_order_updates_and_status.sql
Normal file
48
migrations/246_economic_order_updates_and_status.sql
Normal file
@ -0,0 +1,48 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE ordre_drafts
|
||||
ADD COLUMN IF NOT EXISTS economic_last_checked_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS economic_document_state VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS economic_payment_status VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS economic_status_response JSONB,
|
||||
ADD COLUMN IF NOT EXISTS economic_status_error TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS economic_order_update_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
draft_id INTEGER NOT NULL REFERENCES ordre_drafts(id) ON DELETE CASCADE,
|
||||
economic_order_number VARCHAR(80) NOT NULL,
|
||||
before_snapshot JSONB NOT NULL,
|
||||
proposed_payload JSONB NOT NULL,
|
||||
proposed_local_lines JSONB NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','applied','expired','failed')),
|
||||
requested_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '15 minutes',
|
||||
applied_at TIMESTAMPTZ,
|
||||
response JSONB,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_economic_order_update_pending ON economic_order_update_requests(draft_id,status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ordre_economic_daily_check ON ordre_drafts(economic_last_checked_at) WHERE economic_order_number IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION protect_exported_ordre_lines() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (NEW.lines_json IS DISTINCT FROM OLD.lines_json OR NEW.customer_id IS DISTINCT FROM OLD.customer_id)
|
||||
AND current_setting('bmc.economic_order_update', true) IS DISTINCT FROM OLD.id::text
|
||||
AND EXISTS (SELECT 1 FROM economic_document_exports WHERE document_key='order-draft:'||OLD.id::text AND status NOT IN ('rejected','archived')) THEN
|
||||
RAISE EXCEPTION 'Exported or uncertain document lines are immutable; use the approved e-conomic draft update flow';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
|
||||
INSERT INTO permissions(code,description,category)
|
||||
VALUES ('economic.orders.update','Forhåndsvis og godkend opdatering af en åben e-conomic-ordrekladde','economic')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
INSERT INTO group_permissions(group_id,permission_id)
|
||||
SELECT g.id,p.id FROM groups g CROSS JOIN permissions p
|
||||
WHERE g.name='Administrators' AND p.code='economic.orders.update'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
50
migrations/247_also_order_external_id.sql
Normal file
50
migrations/247_also_order_external_id.sql
Normal file
@ -0,0 +1,50 @@
|
||||
BEGIN;
|
||||
|
||||
-- The old integration message was exposed as "Tekst 1" in e-conomic.
|
||||
-- Keep user-entered notes intact and only remove the exact ALSO system text.
|
||||
UPDATE ordre_drafts
|
||||
SET notes = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE invoice_aggregate_key LIKE 'also-cloud-%'
|
||||
AND notes = 'Genereret fra ALSO Cloud Billing approval';
|
||||
|
||||
-- Add the source service period to existing, still-pending ALSO order lines.
|
||||
WITH rebuilt AS (
|
||||
SELECT d.id,
|
||||
jsonb_agg(
|
||||
CASE
|
||||
WHEN line.item->>'source_type' = 'also_cloud'
|
||||
AND COALESCE(line.item->>'description', '') NOT LIKE '% · Periode: %'
|
||||
AND source.charge_interval ~ '^\s*\d{2}[./-]\d{2}[./-]\d{4}\s*-\s*\d{2}[./-]\d{2}[./-]\d{4}\s*$'
|
||||
THEN jsonb_set(
|
||||
line.item,
|
||||
'{description}',
|
||||
to_jsonb(
|
||||
COALESCE(line.item->>'description', 'Cloud abonnement')
|
||||
|| ' · Periode: '
|
||||
|| replace(source.charge_interval, '/', '.')
|
||||
)
|
||||
)
|
||||
ELSE line.item
|
||||
END
|
||||
ORDER BY line.ordinality
|
||||
) AS lines_json
|
||||
FROM ordre_drafts d
|
||||
CROSS JOIN LATERAL jsonb_array_elements(d.lines_json) WITH ORDINALITY AS line(item, ordinality)
|
||||
LEFT JOIN also_import_lines source
|
||||
ON source.id = CASE
|
||||
WHEN line.item->>'source_id' ~ '^\d+$' THEN (line.item->>'source_id')::bigint
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE d.sync_status = 'pending'
|
||||
AND d.invoice_aggregate_key LIKE 'also-cloud-%'
|
||||
GROUP BY d.id
|
||||
)
|
||||
UPDATE ordre_drafts d
|
||||
SET lines_json = rebuilt.lines_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM rebuilt
|
||||
WHERE d.id = rebuilt.id
|
||||
AND d.lines_json IS DISTINCT FROM rebuilt.lines_json;
|
||||
|
||||
COMMIT;
|
||||
26
migrations/248_also_order_period_line_break.sql
Normal file
26
migrations/248_also_order_period_line_break.sql
Normal file
@ -0,0 +1,26 @@
|
||||
BEGIN;
|
||||
|
||||
-- Put the ALSO billing period on its own line for pending order drafts.
|
||||
UPDATE ordre_drafts
|
||||
SET lines_json = (
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN item->>'source_type' = 'also_cloud'
|
||||
AND COALESCE(item->>'description', '') LIKE '% · Periode: %'
|
||||
THEN jsonb_set(
|
||||
item,
|
||||
'{description}',
|
||||
to_jsonb(replace(item->>'description', ' · Periode: ', E'\nPeriode: '))
|
||||
)
|
||||
ELSE item
|
||||
END
|
||||
ORDER BY ordinality
|
||||
)
|
||||
FROM jsonb_array_elements(lines_json) WITH ORDINALITY AS source(item, ordinality)
|
||||
),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE sync_status = 'pending'
|
||||
AND invoice_aggregate_key LIKE 'also-cloud-%'
|
||||
AND lines_json::text LIKE '% · Periode: %';
|
||||
|
||||
COMMIT;
|
||||
@ -86,6 +86,30 @@ def test_snapshot_preserves_saved_price_and_zero():
|
||||
assert snap['net_total'] == '0.00'
|
||||
|
||||
|
||||
def test_order_external_reference_uses_hub_order_id_and_keeps_legacy_fallback():
|
||||
assert d.external_reference('export-uuid', 16) == '16'
|
||||
assert d.external_reference('export-uuid') == 'BMC-HUB:export-uuid'
|
||||
|
||||
|
||||
def test_also_charge_period_is_added_only_for_an_explicit_date_range():
|
||||
from app.modules.also.backend.service import AlsoService, _format_charge_period
|
||||
|
||||
assert _format_charge_period('01/09/2026 - 30/09/2026') == '01.09.2026 - 30.09.2026'
|
||||
assert _format_charge_period('monthly') is None
|
||||
assert _format_charge_period(None) is None
|
||||
normalized = AlsoService()._normalize_import_row(
|
||||
{
|
||||
'Company': 'Testkunde',
|
||||
'Product Name': 'Microsoft 365',
|
||||
'Total Price': '100',
|
||||
'Actual Charge Interval': '01/09/2026 - 30/09/2026',
|
||||
'Charge Interval': 'monthly',
|
||||
},
|
||||
'csv:1',
|
||||
)
|
||||
assert normalized['charge_interval'] == '01/09/2026 - 30/09/2026'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes', [{'economic_product_number': None}, {'economic_connection_id': 2},
|
||||
{'is_active_in_economic': False}, {'deleted_at': 'today'}, {'status': 'inactive'}])
|
||||
def test_export_invalid_product_blocks(changes):
|
||||
@ -140,6 +164,28 @@ def test_four_eyes_context_allows_only_customer_and_product_put():
|
||||
assert_economic_write_allowed('PUT', 'products/ABC-1')
|
||||
|
||||
|
||||
def test_confirmed_order_context_allows_only_numeric_draft_put():
|
||||
from app.core.economic_write_policy import approved_order_draft_update, assert_economic_write_allowed
|
||||
with pytest.raises(HTTPException):
|
||||
assert_economic_write_allowed('PUT', 'orders/drafts/12')
|
||||
with approved_order_draft_update():
|
||||
assert_economic_write_allowed('PUT', 'orders/drafts/12')
|
||||
with pytest.raises(HTTPException):
|
||||
assert_economic_write_allowed('PUT', 'orders/12')
|
||||
with pytest.raises(HTTPException):
|
||||
assert_economic_write_allowed('DELETE', 'orders/drafts/12')
|
||||
|
||||
|
||||
def test_order_update_payload_strips_read_only_fields_and_keeps_product():
|
||||
from app.modules.orders.backend.economic_sync import build_update_payload
|
||||
payload, _ = build_update_payload({
|
||||
'date':'2026-09-13','currency':'DKK','customer':{'customerNumber':1},
|
||||
'orderNumber':42,'netAmount':100,'lines':[{'lineNumber':1,'product':{'productNumber':'P1'},'quantity':1,'unitNetPrice':10}],
|
||||
}, [{'description':'Ny','quantity':2,'unit_price':25,'discount_percentage':10}], 'Note')
|
||||
assert 'orderNumber' not in payload and 'netAmount' not in payload
|
||||
assert payload['lines'] == [{'product':{'productNumber':'P1'},'description':'Ny','quantity':2.0,'unitNetPrice':25.0,'discountPercentage':10.0}]
|
||||
|
||||
|
||||
def test_four_eyes_change_field_allowlist():
|
||||
from app.products.backend.economic_routes import _validate_change_fields
|
||||
assert _validate_change_fields('product', {'name': 'Nyt navn'}) == {'name': 'Nyt navn'}
|
||||
|
||||
@ -88,3 +88,21 @@ def test_product_creation_preview_values_become_economic_payload():
|
||||
"barred": False,
|
||||
"productGroup": {"productGroupNumber": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_draft_id_is_saved_as_order_number():
|
||||
from app.modules.orders.backend.router import _economic_order_number
|
||||
|
||||
assert _economic_order_number({"economic_draft_id": 4711}) == 4711
|
||||
|
||||
|
||||
def test_exported_status_requires_economic_order_number():
|
||||
from fastapi import HTTPException
|
||||
from app.modules.orders.backend.router import _validate_sync_status_number
|
||||
|
||||
try:
|
||||
_validate_sync_status_number("exported", None, None)
|
||||
assert False, "exported status without an external order number must be rejected"
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 409
|
||||
_validate_sync_status_number("exported", None, "4711")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user