feat(accounting): add invoice preparation workflow

This commit is contained in:
Schubert Ferenc 2026-07-05 13:41:33 +02:00
parent ffffb68898
commit 46eeaa1f2e
17 changed files with 519 additions and 44 deletions

View file

@ -1,5 +1,6 @@
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal, ROUND_HALF_UP
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest
@ -16,6 +17,7 @@ from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.system_settings_repository import SystemSettingsRepository
from app.schemas.lexware import (
AccountingTransferUpdate,
LexwareCustomerMapping,
LexwareInvoicePreparationResponse,
LexwareLineItemMapping,
@ -276,7 +278,160 @@ class LexwareService:
)
for item in estimate.items
]
payload_summary = {
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
record = LexwareSyncRecord(
entity_type="repair_estimate",
entity_id=estimate.id,
lexware_resource_type="invoice",
status="pending" if not warnings else "skipped",
direction="push",
export_status="prepared",
accounting_note=estimate.accounting_note,
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
error_message="; ".join(warnings) if warnings else None,
)
estimate.accounting_export_status = "prepared"
db.add(record)
db.commit()
db.refresh(record)
db.refresh(estimate)
write_audit_log(
db,
action="accounting.invoice.handoff",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"ready_for_export": not warnings,
"sync_record_id": record.id,
"export_status": record.export_status,
},
)
return LexwareService._invoice_preparation_response(
record=record,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_items=line_items,
estimate=estimate,
config=config,
warnings=warnings,
)
@staticmethod
def mark_transferred(
db: Session,
repair: Repair,
estimate: RepairEstimate,
payload: AccountingTransferUpdate,
*,
actor: User,
request: Request,
) -> LexwareInvoicePreparationResponse:
record = LexwareService._latest_invoice_record(db, estimate.id)
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
if record.export_status not in {"prepared", "transferred"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
now = datetime.now(UTC)
record.export_status = "transferred"
record.status = "success"
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
record.transferred_at = now
record.transferred_by_user_id = actor.id
record.synced_at = now
estimate.accounting_export_status = "transferred"
estimate.accounting_note = record.accounting_note
estimate.accounting_transferred_at = now
estimate.accounting_transferred_by_user_id = actor.id
db.commit()
db.refresh(record)
db.refresh(estimate)
write_audit_log(
db,
action="accounting.invoice.mark_transferred",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"sync_record_id": record.id,
"export_status": record.export_status,
"transferred_at": record.transferred_at,
},
)
if note_changed:
write_audit_log(
db,
action="accounting.invoice.note_update",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
)
config = LexwareService.get_runtime_config(db)
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
warnings: list[str] = []
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
return LexwareService._invoice_preparation_response(
record=record,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_items=line_items,
estimate=estimate,
config=config,
warnings=warnings,
)
@staticmethod
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
customer_payload = {
"roles": {"customer": {}},
"company": {"name": repair.customer_name},
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
}
customer_mapping = LexwareCustomerMapping(
name=repair.customer_name,
email=repair.customer_email,
phone=repair.customer_phone,
search_strategy="email" if repair.customer_email else "name",
create_payload=customer_payload,
)
line_items = [
LexwareLineItemMapping(
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price=_euros(item.unit_price_cents),
tax_rate=estimate.tax_rate_percent,
total=_euros(item.total_cents),
)
for item in estimate.items
]
return customer_mapping, line_items
@staticmethod
def _invoice_payload_summary(
config: LexwareRuntimeConfig,
repair: Repair,
estimate: RepairEstimate,
line_items: list[LexwareLineItemMapping],
) -> dict:
return {
"type": "invoice",
"title": f"Rechnung zu Reparatur {repair.repair_number}",
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
@ -289,35 +444,21 @@ class LexwareService:
"total": str(_euros(estimate.total_cents)),
"line_item_count": len(line_items),
}
record = LexwareSyncRecord(
entity_type="repair_estimate",
entity_id=estimate.id,
lexware_resource_type="invoice",
status="pending" if not warnings else "skipped",
direction="push",
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
error_message="; ".join(warnings) if warnings else None,
)
db.add(record)
db.commit()
db.refresh(record)
write_audit_log(
db,
action="lexware.invoice.prepare",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"ready_for_export": not warnings,
"sync_record_id": record.id,
},
)
@staticmethod
def _invoice_preparation_response(
*,
record: LexwareSyncRecord,
payload_summary: dict,
customer_mapping: LexwareCustomerMapping,
line_items: list[LexwareLineItemMapping],
estimate: RepairEstimate,
config: LexwareRuntimeConfig,
warnings: list[str],
) -> LexwareInvoicePreparationResponse:
return LexwareInvoicePreparationResponse(
ready_for_export=not warnings,
export_status=record.export_status,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_item_mapping=line_items,
@ -329,6 +470,22 @@ class LexwareService:
},
warnings=warnings,
sync_record_id=record.id,
accounting_note=record.accounting_note or "",
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
transferred_by_user_id=record.transferred_by_user_id,
)
@staticmethod
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
from sqlalchemy import select
return db.scalar(
select(LexwareSyncRecord)
.where(LexwareSyncRecord.entity_type == "repair_estimate")
.where(LexwareSyncRecord.entity_id == estimate_id)
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
.limit(1)
)
@staticmethod