feat(accounting): add invoice preparation workflow
This commit is contained in:
parent
ffffb68898
commit
46eeaa1f2e
17 changed files with 519 additions and 44 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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."):
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue