From 46eeaa1f2e47c0513e2e33539fd20d1266ffe50d Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 13:41:33 +0200 Subject: [PATCH 1/3] feat(accounting): add invoice preparation workflow --- ARCHITECTURE.md | 10 + README-DEV.md | 9 + ROADMAP.md | 3 + ...e3f4a5b6c7_add_accounting_export_status.py | 53 +++++ backend/hermes/app/api/audit.py | 2 + backend/hermes/app/api/dashboard.py | 29 +++ backend/hermes/app/api/lexware.py | 22 +- backend/hermes/app/models/lexware.py | 4 + backend/hermes/app/models/repair_estimate.py | 4 + backend/hermes/app/schemas/lexware.py | 15 ++ backend/hermes/app/schemas/repair_estimate.py | 4 + backend/hermes/app/services/audit_service.py | 3 + .../hermes/app/services/lexware_service.py | 213 +++++++++++++++--- .../accounting/mark-transferred/route.ts | 19 ++ .../repairs/RepairEstimatesSection.tsx | 165 ++++++++++++-- frontend/athena/types/lexware.ts | 4 + frontend/athena/types/repair.ts | 4 + 17 files changed, 519 insertions(+), 44 deletions(-) create mode 100644 backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py create mode 100644 frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a35aa71..62a0499 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -183,6 +183,7 @@ Komponenten: - 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`. - 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: @@ -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. +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: - `GET /lexware/settings` diff --git a/README-DEV.md b/README-DEV.md index ebb89d2..3aee4d1 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -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. +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: - Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`. diff --git a/ROADMAP.md b/ROADMAP.md index 20bd205..040120d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,6 +190,9 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs - Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe - 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 diff --git a/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py new file mode 100644 index 0000000..0c7936b --- /dev/null +++ b/backend/hermes/alembic/versions/d2e3f4a5b6c7_add_accounting_export_status.py @@ -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") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py index eb8ed14..8349be7 100644 --- a/backend/hermes/app/api/audit.py +++ b/backend/hermes/app/api/audit.py @@ -64,6 +64,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool: return "inventory.read" in permissions if action.startswith("lexware."): return "lexware.read" in permissions + if action.startswith("accounting."): + return "lexware.read" in permissions if action.startswith("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 2f2c197..7488a6d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -8,6 +8,7 @@ from app.core.rbac import get_user_permission_names, require_permission from app.db.database import get_db from app.models.rbac import Role from app.models.audit import AuditLog +from app.models.lexware import LexwareSyncRecord from app.models.user import User from app.repositories.customer_repository import CustomerRepository 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)), ]) + 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: inventory = [ MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), diff --git a/backend/hermes/app/api/lexware.py b/backend/hermes/app/api/lexware.py index fc255f9..03b20c0 100644 --- a/backend/hermes/app/api/lexware.py +++ b/backend/hermes/app/api/lexware.py @@ -9,7 +9,13 @@ from app.models.repair_estimate import RepairEstimate from app.models.user import User from app.repositories.repair_estimate_repository import RepairEstimateRepository 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 router = APIRouter(tags=["Lexware"]) @@ -67,3 +73,17 @@ def prepare_lexware_invoice( repair = get_repair_or_404(db, repair_id) estimate = get_estimate_or_404(db, repair_id, estimate_id) 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) diff --git a/backend/hermes/app/models/lexware.py b/backend/hermes/app/models/lexware.py index 5f07528..febb874 100644 --- a/backend/hermes/app/models/lexware.py +++ b/backend/hermes/app/models/lexware.py @@ -16,8 +16,12 @@ class LexwareSyncRecord(Base): 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) 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) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) 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()) 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) diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index b98603b..b9d6a80 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -31,6 +31,10 @@ class RepairEstimate(Base): 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_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_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()) diff --git a/backend/hermes/app/schemas/lexware.py b/backend/hermes/app/schemas/lexware.py index 7723042..45cd103 100644 --- a/backend/hermes/app/schemas/lexware.py +++ b/backend/hermes/app/schemas/lexware.py @@ -8,6 +8,7 @@ from app.schemas.system_setting import SettingsSource, normalize_text LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"] LexwareSyncDirection = Literal["push", "pull"] +AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"] class LexwareSettingsResponse(BaseModel): @@ -73,9 +74,23 @@ class LexwareLineItemMapping(BaseModel): class LexwareInvoicePreparationResponse(BaseModel): ready_for_export: bool + export_status: AccountingExportStatus payload_summary: dict customer_mapping: LexwareCustomerMapping line_item_mapping: list[LexwareLineItemMapping] tax_mapping: dict warnings: list[str] 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 diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index 404c0a5..b1b68e5 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -146,6 +146,10 @@ class RepairEstimateResponse(BaseModel): lexware_invoice_number: str | None lexware_invoice_status: str | 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_at: datetime updated_at: datetime diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 9ae8a41..12518a6 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -212,6 +212,9 @@ def action_title(action: str) -> str: "lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen", "lexware.invoice.prepare": "Lexware Rechnung vorbereitet", "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) diff --git a/backend/hermes/app/services/lexware_service.py b/backend/hermes/app/services/lexware_service.py index 90c70c2..61ffe38 100644 --- a/backend/hermes/app/services/lexware_service.py +++ b/backend/hermes/app/services/lexware_service.py @@ -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 diff --git a/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts new file mode 100644 index 0000000..c95e919 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/estimates/[estimateId]/accounting/mark-transferred/route.ts @@ -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`); +} diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 210e415..58b89f1 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } 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 { useToast } from "@/components/common/ToastProvider"; @@ -46,6 +46,20 @@ const statusLabels: Record = { revoked: "Zurückgenommen", }; +const accountingStatusLabels: Record = { + prepared: "Vorbereitet", + transferred: "Übertragen", + booked: "Gebucht", + cancelled: "Storniert", +}; + +const accountingStatusClasses: Record = { + 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 { if (!Array.isArray(detail)) { return null; @@ -193,6 +207,8 @@ export default function RepairEstimatesSection({ const [revokeTarget, setRevokeTarget] = useState(null); const [lexwareResult, setLexwareResult] = useState(null); const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false); + const [accountingTarget, setAccountingTarget] = useState(null); + const [accountingNote, setAccountingNote] = useState(""); const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); const [inventoryItems, setInventoryItems] = useState([]); const [inventoryCategories, setInventoryCategories] = useState([]); @@ -417,19 +433,48 @@ export default function RepairEstimatesSection({ try { const response = await api.post(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`); setLexwareResult(response.data); + setAccountingTarget(estimate); + setAccountingNote(response.data.accounting_note || estimate.accounting_note || ""); setLexwareDialogOpen(true); showToast({ type: response.data.ready_for_export ? "success" : "error", - title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung 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.", + title: response.data.ready_for_export ? "Rechnungsvorbereitung erstellt" : "Rechnungsvorbereitung mit Hinweisen", + 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) { - showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) }); + showToast({ type: "error", title: "Rechnungsvorbereitung fehlgeschlagen", description: getErrorMessage(err) }); } finally { setPendingId(null); } } + async function markAccountingTransferred() { + if (!accountingTarget || !lexwareResult) return; + setPendingId(accountingTarget.id); + try { + const response = await api.post( + `/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() { if (!deleteTarget) return; setPendingId(deleteTarget.id); @@ -494,6 +539,16 @@ export default function RepairEstimatesSection({ + {estimate.accounting_export_status && ( +
+ + Buchhaltung + + {estimate.accounting_transferred_at && {dateTime(estimate.accounting_transferred_at)}} + {estimate.accounting_note && {estimate.accounting_note}} +
+ )} +
{estimate.items.map((item) => (
@@ -519,7 +574,7 @@ export default function RepairEstimatesSection({ {canSend && ["draft", "sent"].includes(estimate.status) && } {(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && } {canRevoke && estimate.status === "approved" && } - {canLexwareExport && estimate.status === "approved" && } + {canLexwareExport && estimate.status === "approved" && } {canDelete && ["draft", "cancelled"].includes(estimate.status) && }
@@ -656,14 +711,22 @@ export default function RepairEstimatesSection({ - Lexware-Rechnung vorbereiten - Die Rechnung wird noch nicht automatisch in Lexware erstellt. + In Buchhaltung übernehmen + Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe. {lexwareResult && (
-
-

{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}

-

Sync-Record #{lexwareResult.sync_record_id}

+
+
+ Exportstatus + + + + + +
+

Rechnungsvorbereitung #{lexwareResult.sync_record_id}

+ {lexwareResult.transferred_at &&

Übertragen am {dateTime(lexwareResult.transferred_at)}

}
{lexwareResult.warnings.length > 0 && ( @@ -675,14 +738,21 @@ export default function RepairEstimatesSection({
)} -
- - - - +
+ + void copyText("Kundendaten", text)} + /> +
+
Position Steuer @@ -698,6 +768,13 @@ export default function RepairEstimatesSection({ {item.total} €
))} + ( + `${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)} + /> +
@@ -705,6 +782,33 @@ export default function RepairEstimatesSection({
+ +
+ + +
+