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

@ -183,6 +183,7 @@ Komponenten:
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`. - Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`. - Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden. - Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
Datenfluss: Datenfluss:
@ -193,6 +194,15 @@ Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreS
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert. Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
Buchhaltungsworkflow:
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
Neue zentrale Endpunkte: Neue zentrale Endpunkte:
- `GET /lexware/settings` - `GET /lexware/settings`

View file

@ -210,6 +210,15 @@ POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice
v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag. v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag.
Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
Exportstatus:
- `prepared`
- `transferred`
- `booked`
- `cancelled`
Benachrichtigungen: Benachrichtigungen:
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`. - Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.

View file

@ -190,6 +190,9 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs - Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs
- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe - Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe
- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export` - RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export`
- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled`
- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware
- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen
## v0.9.0 - Lexware Rechnungserstellung, geplant ## v0.9.0 - Lexware Rechnungserstellung, geplant

View file

@ -0,0 +1,53 @@
"""add accounting export status
Revision ID: d2e3f4a5b6c7
Revises: c9d4e5f6a7b8
Create Date: 2026-07-05 17:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d2e3f4a5b6c7"
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_repair_estimates_accounting_transferred_by_user_id",
"repair_estimates",
"users",
["accounting_transferred_by_user_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
op.drop_column("repair_estimates", "accounting_transferred_at")
op.drop_column("repair_estimates", "accounting_note")
op.drop_column("repair_estimates", "accounting_export_status")
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
op.drop_column("lexware_sync_records", "transferred_by_user_id")
op.drop_column("lexware_sync_records", "transferred_at")
op.drop_column("lexware_sync_records", "accounting_note")
op.drop_column("lexware_sync_records", "export_status")

View file

@ -64,6 +64,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "inventory.read" in permissions return "inventory.read" in permissions
if action.startswith("lexware."): if action.startswith("lexware."):
return "lexware.read" in permissions return "lexware.read" in permissions
if action.startswith("accounting."):
return "lexware.read" in permissions
if action.startswith("audit_logs."): if action.startswith("audit_logs."):
return "audit_logs.read" in permissions return "audit_logs.read" in permissions
if action.startswith("auth."): if action.startswith("auth."):

View file

@ -8,6 +8,7 @@ from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db from app.db.database import get_db
from app.models.rbac import Role from app.models.rbac import Role
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.lexware import LexwareSyncRecord
from app.models.user import User from app.models.user import User
from app.repositories.customer_repository import CustomerRepository from app.repositories.customer_repository import CustomerRepository
from app.repositories.inventory_repository import InventoryRepository from app.repositories.inventory_repository import InventoryRepository
@ -85,6 +86,34 @@ def get_dashboard_summary(
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)), MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
]) ])
if "lexware.read" in permissions:
repairs.extend([
MetricCard(
label="Vorbereitete Rechnungen",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "prepared")
) or 0,
),
MetricCard(
label="An Buchhaltung übergeben",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "transferred")
) or 0,
),
MetricCard(
label="Noch nicht übertragen",
value=db.scalar(
select(func.count(LexwareSyncRecord.id))
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
.where(LexwareSyncRecord.export_status == "prepared")
) or 0,
),
])
if "inventory.read" in permissions: if "inventory.read" in permissions:
inventory = [ inventory = [
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),

View file

@ -9,7 +9,13 @@ from app.models.repair_estimate import RepairEstimate
from app.models.user import User from app.models.user import User
from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository from app.repositories.repair_repository import RepairRepository
from app.schemas.lexware import LexwareInvoicePreparationResponse, LexwareSettingsResponse, LexwareSettingsUpdate, LexwareTestConnectionResponse from app.schemas.lexware import (
AccountingTransferUpdate,
LexwareInvoicePreparationResponse,
LexwareSettingsResponse,
LexwareSettingsUpdate,
LexwareTestConnectionResponse,
)
from app.services.lexware_service import LexwareService from app.services.lexware_service import LexwareService
router = APIRouter(tags=["Lexware"]) router = APIRouter(tags=["Lexware"])
@ -67,3 +73,17 @@ def prepare_lexware_invoice(
repair = get_repair_or_404(db, repair_id) repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id) estimate = get_estimate_or_404(db, repair_id, estimate_id)
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request) return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
def mark_accounting_transferred(
repair_id: int,
estimate_id: int,
payload: AccountingTransferUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("lexware.export")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)

View file

@ -16,8 +16,12 @@ class LexwareSyncRecord(Base):
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True) status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True) direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True) payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)

View file

@ -31,6 +31,10 @@ class RepairEstimate(Base):
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True) lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True) lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View file

@ -8,6 +8,7 @@ from app.schemas.system_setting import SettingsSource, normalize_text
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"] LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
LexwareSyncDirection = Literal["push", "pull"] LexwareSyncDirection = Literal["push", "pull"]
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
class LexwareSettingsResponse(BaseModel): class LexwareSettingsResponse(BaseModel):
@ -73,9 +74,23 @@ class LexwareLineItemMapping(BaseModel):
class LexwareInvoicePreparationResponse(BaseModel): class LexwareInvoicePreparationResponse(BaseModel):
ready_for_export: bool ready_for_export: bool
export_status: AccountingExportStatus
payload_summary: dict payload_summary: dict
customer_mapping: LexwareCustomerMapping customer_mapping: LexwareCustomerMapping
line_item_mapping: list[LexwareLineItemMapping] line_item_mapping: list[LexwareLineItemMapping]
tax_mapping: dict tax_mapping: dict
warnings: list[str] warnings: list[str]
sync_record_id: int sync_record_id: int
accounting_note: str = ""
transferred_at: str | None = None
transferred_by_user_id: int | None = None
class AccountingTransferUpdate(BaseModel):
accounting_note: str | None = Field(default=None, max_length=2000)
@field_validator("accounting_note", mode="before")
@classmethod
def normalize_note(cls, value: object) -> str | None:
text = normalize_text(value)
return text or None

View file

@ -146,6 +146,10 @@ class RepairEstimateResponse(BaseModel):
lexware_invoice_number: str | None lexware_invoice_number: str | None
lexware_invoice_status: str | None lexware_invoice_status: str | None
lexware_synced_at: datetime | None lexware_synced_at: datetime | None
accounting_export_status: str | None
accounting_note: str | None
accounting_transferred_at: datetime | None
accounting_transferred_by_user_id: int | None
created_by_user_id: int | None created_by_user_id: int | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View file

@ -212,6 +212,9 @@ def action_title(action: str) -> str:
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen", "lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet", "lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen", "lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
} }
return labels.get(action, action) return labels.get(action, action)

View file

@ -1,5 +1,6 @@
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest 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.models.user import User
from app.repositories.system_settings_repository import SystemSettingsRepository from app.repositories.system_settings_repository import SystemSettingsRepository
from app.schemas.lexware import ( from app.schemas.lexware import (
AccountingTransferUpdate,
LexwareCustomerMapping, LexwareCustomerMapping,
LexwareInvoicePreparationResponse, LexwareInvoicePreparationResponse,
LexwareLineItemMapping, LexwareLineItemMapping,
@ -276,7 +278,160 @@ class LexwareService:
) )
for item in estimate.items 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", "type": "invoice",
"title": f"Rechnung zu Reparatur {repair.repair_number}", "title": f"Rechnung zu Reparatur {repair.repair_number}",
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_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)), "total": str(_euros(estimate.total_cents)),
"line_item_count": len(line_items), "line_item_count": len(line_items),
} }
record = LexwareSyncRecord(
entity_type="repair_estimate", @staticmethod
entity_id=estimate.id, def _invoice_preparation_response(
lexware_resource_type="invoice", *,
status="pending" if not warnings else "skipped", record: LexwareSyncRecord,
direction="push", payload_summary: dict,
payload_summary=json.dumps(payload_summary, ensure_ascii=True), customer_mapping: LexwareCustomerMapping,
error_message="; ".join(warnings) if warnings else None, line_items: list[LexwareLineItemMapping],
) estimate: RepairEstimate,
db.add(record) config: LexwareRuntimeConfig,
db.commit() warnings: list[str],
db.refresh(record) ) -> LexwareInvoicePreparationResponse:
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,
},
)
return LexwareInvoicePreparationResponse( return LexwareInvoicePreparationResponse(
ready_for_export=not warnings, ready_for_export=not warnings,
export_status=record.export_status,
payload_summary=payload_summary, payload_summary=payload_summary,
customer_mapping=customer_mapping, customer_mapping=customer_mapping,
line_item_mapping=line_items, line_item_mapping=line_items,
@ -329,6 +470,22 @@ class LexwareService:
}, },
warnings=warnings, warnings=warnings,
sync_record_id=record.id, 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 @staticmethod

View file

@ -0,0 +1,19 @@
import { NextRequest } from "next/server";
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{ id: string; estimateId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const { id, estimateId } = await params;
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`);
}

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AlertTriangle, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react"; import { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import { useToast } from "@/components/common/ToastProvider"; import { useToast } from "@/components/common/ToastProvider";
@ -46,6 +46,20 @@ const statusLabels: Record<string, string> = {
revoked: "Zurückgenommen", revoked: "Zurückgenommen",
}; };
const accountingStatusLabels: Record<string, string> = {
prepared: "Vorbereitet",
transferred: "Übertragen",
booked: "Gebucht",
cancelled: "Storniert",
};
const accountingStatusClasses: Record<string, string> = {
prepared: "bg-blue-50 text-blue-700 ring-blue-600/20",
transferred: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
booked: "bg-slate-900 text-white ring-slate-900/20",
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
};
function humanizeValidationDetail(detail: unknown): string | null { function humanizeValidationDetail(detail: unknown): string | null {
if (!Array.isArray(detail)) { if (!Array.isArray(detail)) {
return null; return null;
@ -193,6 +207,8 @@ export default function RepairEstimatesSection({
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null); const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null); const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false); const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
const [accountingTarget, setAccountingTarget] = useState<RepairEstimate | null>(null);
const [accountingNote, setAccountingNote] = useState("");
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]); const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]); const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
@ -417,19 +433,48 @@ export default function RepairEstimatesSection({
try { try {
const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`); const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`);
setLexwareResult(response.data); setLexwareResult(response.data);
setAccountingTarget(estimate);
setAccountingNote(response.data.accounting_note || estimate.accounting_note || "");
setLexwareDialogOpen(true); setLexwareDialogOpen(true);
showToast({ showToast({
type: response.data.ready_for_export ? "success" : "error", type: response.data.ready_for_export ? "success" : "error",
title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen", title: response.data.ready_for_export ? "Rechnungsvorbereitung erstellt" : "Rechnungsvorbereitung mit Hinweisen",
description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.", description: response.data.ready_for_export ? "Die Daten stehen für die Übernahme in die Buchhaltung bereit." : "Bitte prüfe die Hinweise vor der Übernahme.",
}); });
} catch (err) { } catch (err) {
showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) }); showToast({ type: "error", title: "Rechnungsvorbereitung fehlgeschlagen", description: getErrorMessage(err) });
} finally { } finally {
setPendingId(null); setPendingId(null);
} }
} }
async function markAccountingTransferred() {
if (!accountingTarget || !lexwareResult) return;
setPendingId(accountingTarget.id);
try {
const response = await api.post<LexwareInvoicePreparation>(
`/repairs/${repairId}/estimates/${accountingTarget.id}/accounting/mark-transferred`,
{ accounting_note: accountingNote },
);
setLexwareResult(response.data);
await loadEstimates();
showToast({ type: "success", title: "Als übertragen markiert", description: "Die Übergabe an die Buchhaltung wurde dokumentiert." });
} catch (err) {
showToast({ type: "error", title: "Status konnte nicht aktualisiert werden", description: getErrorMessage(err) });
} finally {
setPendingId(null);
}
}
async function copyText(label: string, text: string) {
try {
await navigator.clipboard.writeText(text);
showToast({ type: "success", title: `${label} kopiert` });
} catch {
showToast({ type: "error", title: "Kopieren fehlgeschlagen", description: "Bitte markiere den Text manuell." });
}
}
async function deleteEstimate() { async function deleteEstimate() {
if (!deleteTarget) return; if (!deleteTarget) return;
setPendingId(deleteTarget.id); setPendingId(deleteTarget.id);
@ -494,6 +539,16 @@ export default function RepairEstimatesSection({
<Meta label="Antwort" value={estimate.customer_response_message || "-"} /> <Meta label="Antwort" value={estimate.customer_response_message || "-"} />
</dl> </dl>
{estimate.accounting_export_status && (
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
<ReceiptText className="h-4 w-4 text-slate-500" />
<span className="font-medium text-slate-700">Buchhaltung</span>
<AccountingBadge status={estimate.accounting_export_status} />
{estimate.accounting_transferred_at && <span className="text-xs text-slate-500">{dateTime(estimate.accounting_transferred_at)}</span>}
{estimate.accounting_note && <span className="break-words text-xs text-slate-500">{estimate.accounting_note}</span>}
</div>
)}
<div className="mt-4 divide-y rounded-lg border"> <div className="mt-4 divide-y rounded-lg border">
{estimate.items.map((item) => ( {estimate.items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm"> <div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
@ -519,7 +574,7 @@ export default function RepairEstimatesSection({
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>} {canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>} {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>} {canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>}
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />Lexware-Rechnung vorbereiten</Button>} {canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />In Buchhaltung übernehmen</Button>}
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>} {canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
</div> </div>
</article> </article>
@ -656,14 +711,22 @@ export default function RepairEstimatesSection({
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}> <Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
<DialogContent className="sm:max-w-3xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Lexware-Rechnung vorbereiten</DialogTitle> <DialogTitle>In Buchhaltung übernehmen</DialogTitle>
<DialogDescription>Die Rechnung wird noch nicht automatisch in Lexware erstellt.</DialogDescription> <DialogDescription>Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe.</DialogDescription>
</DialogHeader> </DialogHeader>
{lexwareResult && ( {lexwareResult && (
<div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm"> <div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm">
<div className={`rounded-lg border p-4 ${lexwareResult.ready_for_export ? "bg-emerald-50 text-emerald-950" : "bg-amber-50 text-amber-950"}`}> <div className="rounded-lg border bg-slate-50 p-4">
<p className="font-semibold">{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}</p> <div className="flex flex-wrap items-center gap-2">
<p className="mt-1">Sync-Record #{lexwareResult.sync_record_id}</p> <span className="font-semibold text-slate-950">Exportstatus</span>
<AccountingBadge status={lexwareResult.export_status} />
<span className="text-slate-400"></span>
<AccountingBadge status="transferred" muted={lexwareResult.export_status !== "transferred" && lexwareResult.export_status !== "booked"} />
<span className="text-slate-400"></span>
<AccountingBadge status="booked" muted={lexwareResult.export_status !== "booked"} />
</div>
<p className="mt-2 text-xs text-slate-500">Rechnungsvorbereitung #{lexwareResult.sync_record_id}</p>
{lexwareResult.transferred_at && <p className="mt-1 text-xs text-slate-500">Übertragen am {dateTime(lexwareResult.transferred_at)}</p>}
</div> </div>
{lexwareResult.warnings.length > 0 && ( {lexwareResult.warnings.length > 0 && (
@ -675,14 +738,21 @@ export default function RepairEstimatesSection({
</div> </div>
)} )}
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-2"> <div className="rounded-lg border">
<Meta label="Kunde" value={lexwareResult.customer_mapping.name} /> <StepHeader step="1" title="Kundendaten kopieren" />
<Meta label="E-Mail" value={lexwareResult.customer_mapping.email || "-"} /> <CopyBlock
<Meta label="Telefon" value={lexwareResult.customer_mapping.phone || "-"} /> text={[
<Meta label="Kontaktabgleich" value={lexwareResult.customer_mapping.search_strategy} /> `Kunde: ${lexwareResult.customer_mapping.name}`,
`E-Mail: ${lexwareResult.customer_mapping.email || "-"}`,
`Telefon: ${lexwareResult.customer_mapping.phone || "-"}`,
].join("\n")}
onCopy={(text) => void copyText("Kundendaten", text)}
/>
<StepHeader step="2" title="In Buchhaltungssoftware einfügen" muted />
</div> </div>
<div className="rounded-lg border"> <div className="rounded-lg border">
<StepHeader step="3" title="Positionen kopieren" />
<div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500"> <div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500">
<span>Position</span> <span>Position</span>
<span>Steuer</span> <span>Steuer</span>
@ -698,6 +768,13 @@ export default function RepairEstimatesSection({
<span className="font-medium text-slate-950">{item.total} </span> <span className="font-medium text-slate-950">{item.total} </span>
</div> </div>
))} ))}
<CopyBlock
text={lexwareResult.line_item_mapping.map((item) => (
`${item.title}; ${item.description || "-"}; ${item.quantity} ${item.unit}; ${item.unit_price} EUR; ${item.tax_rate}%; ${item.total} EUR`
)).join("\n")}
onCopy={(text) => void copyText("Positionen", text)}
/>
<StepHeader step="4" title="In Buchhaltungssoftware einfügen" muted />
</div> </div>
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3"> <div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
@ -705,6 +782,33 @@ export default function RepairEstimatesSection({
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")}`} /> <Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")}`} />
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")}`} /> <Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")}`} />
</div> </div>
<div className="rounded-lg border bg-white">
<StepHeader step="5" title="Rechnung speichern" muted />
<StepHeader step="6" title="Zurück zu Olympus" muted />
<div className="border-t p-4">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Buchhaltungsnotiz</span>
<textarea
className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
placeholder="z. B. Lexware RG-2026-154 oder Rechnung in sevdesk erstellt"
value={accountingNote}
onChange={(event) => setAccountingNote(event.target.value)}
/>
</label>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
onClick={() => void markAccountingTransferred()}
disabled={!accountingTarget || pendingId === accountingTarget.id || lexwareResult.export_status === "transferred"}
>
<CheckCircle2 />
Als übertragen markieren
</Button>
{lexwareResult.export_status === "transferred" && <span className="text-sm font-medium text-emerald-700">Bereits übertragen</span>}
</div>
</div>
</div>
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
@ -760,6 +864,37 @@ function Meta({ label, value }: { label: string; value: string }) {
); );
} }
function AccountingBadge({ status, muted = false }: { status: string; muted?: boolean }) {
const label = accountingStatusLabels[status] ?? status;
const activeClass = accountingStatusClasses[status] ?? "bg-slate-100 text-slate-700 ring-slate-600/20";
return (
<span className={`w-fit rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${muted ? "bg-slate-100 text-slate-400 ring-slate-200" : activeClass}`}>
{label}
</span>
);
}
function StepHeader({ step, title, muted = false }: { step: string; title: string; muted?: boolean }) {
return (
<div className={`flex items-center gap-3 border-b p-3 ${muted ? "bg-slate-50 text-slate-500" : "bg-white text-slate-950"}`}>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{step}</span>
<p className="font-medium">{title}</p>
</div>
);
}
function CopyBlock({ text, onCopy }: { text: string; onCopy: (text: string) => void }) {
return (
<div className="grid gap-3 p-3">
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-white whitespace-pre-wrap">{text}</pre>
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => onCopy(text)}>
<Copy />
Kopieren
</Button>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) { function Field({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<label className="block"> <label className="block">

View file

@ -47,10 +47,14 @@ export interface LexwareLineItemMapping {
export interface LexwareInvoicePreparation { export interface LexwareInvoicePreparation {
ready_for_export: boolean; ready_for_export: boolean;
export_status: "prepared" | "transferred" | "booked" | "cancelled";
payload_summary: Record<string, unknown>; payload_summary: Record<string, unknown>;
customer_mapping: LexwareCustomerMapping; customer_mapping: LexwareCustomerMapping;
line_item_mapping: LexwareLineItemMapping[]; line_item_mapping: LexwareLineItemMapping[];
tax_mapping: Record<string, unknown>; tax_mapping: Record<string, unknown>;
warnings: string[]; warnings: string[];
sync_record_id: number; sync_record_id: number;
accounting_note: string;
transferred_at: string | null;
transferred_by_user_id: number | null;
} }

View file

@ -220,6 +220,10 @@ export interface RepairEstimate {
lexware_invoice_number: string | null; lexware_invoice_number: string | null;
lexware_invoice_status: string | null; lexware_invoice_status: string | null;
lexware_synced_at: string | null; lexware_synced_at: string | null;
accounting_export_status: "prepared" | "transferred" | "booked" | "cancelled" | null;
accounting_note: string | null;
accounting_transferred_at: string | null;
accounting_transferred_by_user_id: number | null;
created_by_user_id: number | null; created_by_user_id: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;