feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI

This commit is contained in:
2026-07-17 01:58:34 +02:00
parent a38d340ddc
commit 6603e411e9
33 changed files with 3481 additions and 107 deletions
+42 -27
View File
@@ -1,37 +1,52 @@
# Current Status
## T05: Dateiablage pro Fahrzeug + File UI
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
**Status:** COMPLETED
### Backend
-File model (app/models/file.py) - UUID PK, vehicle_id FK, all fields
-File schemas (app/schemas/file.py) - FileUploadResponse, FileResponse, FileListResponse, FileDeleteResponse
-File service (app/services/file_service.py) - upload, get, list, delete, validate_mime_type, validate_file_size
-Thumbnail utils (app/utils/thumbnails.py) - 200x200 thumbnails via Pillow for jpg/png/webp
-Files router (app/routers/files.py) - GET/POST/GET/DELETE endpoints registered in main.py
-Backend tests (tests/test_files.py) - 43 tests, all passing, 82% coverage
-Sale model (app/models/sale.py) - UUID PK, vehicle_id FK, buyer_contact_id FK, seller_contact_id FK nullable, sale_price DECIMAL, sale_date, status enum, is_gwg BOOLEAN, contract_pdf_path
-DATEVExport model (app/models/datev_export.py) - UUID PK, start_date, end_date, file_path, total_amount, created_at
-Sale schemas (app/schemas/sale.py) - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse
-DATEV schemas (app/schemas/datev.py) - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse
-Sale service (app/services/sale_service.py) - create_sale, get_sale, list_sales, update_sale, cancel_sale, delete_sale, regenerate_contract_pdf, verify_ust_id
-DATEV service (app/services/datev_service.py) - create_export, list_exports, get_export_csv
- ✅ Contract PDF utils (app/utils/contract_pdf.py) - HTML template with WeasyPrint, GwG-Klausel, USt-IdNr. field, async via asyncio.to_thread
- ✅ DATEV CSV utils (app/utils/datev.py) - Buchungsstapel format: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
- ✅ Sales router (app/routers/sales.py) - GET/POST/PUT/DELETE /sales, POST/GET /sales/:id/contract, POST /sales/:id/verify-ust-id
- ✅ DATEV router (app/routers/datev.py) - POST /datev/export, GET /datev/exports, GET /datev/exports/:id/download
- ✅ Config updated - BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR
- ✅ main.py updated - sales + datev routers registered
- ✅ requirements.txt - weasyprint>=62.0 added
- ✅ models/__init__.py - all models imported for Base.metadata registration
- ✅ Backend tests (tests/test_sales.py, tests/test_datev.py) - 34 tests, all passing
### Frontend
-Files lib (lib/files.ts) - API functions, types, utilities
-FileUpload component - Drag-and-drop multi-file upload
-FileList component - File list with thumbnails, delete confirm dialog
-FileGallery component - Gallery view for vehicle images
-FilePreview component - File preview modal
-Frontend tests (tests/files.test.tsx) - 25 tests, all passing
-TypeScript build check passes
-Sales lib (lib/sales.ts) - listSales, getSale, createSale, updateSale, deleteSale, regenerateContract, verifyUstId
-DATEV lib (lib/datev.ts) - createDatevExport, listDatevExports, getDatevDownloadUrl
-SaleList component - Sales table with status + date range filters, pagination
-SaleForm component - Sale create form with vehicle select, buyer input, price, GwG toggle
-ContractPreview component - PDF embed preview, regenerate button, download link
-DatevExport component - Date range picker, export list table, CSV download
-Pages: verkauf/page.tsx, verkauf/[id]/page.tsx, verkauf/neu/page.tsx
- ✅ Frontend tests (tests/sales.test.tsx, tests/datev.test.tsx) - 16 tests, all passing
### Acceptance Criteria Met
- ✅ GET /api/v1/vehicles/:id/files → 200 + list of files
-POST /api/v1/vehicles/:id/files mit multipart image → 201 + file object
- ✅ POST /api/v1/vehicles/:id/files mit >20MB file → 413
- ✅ POST /api/v1/vehicles/:id/files mit invalid MIME type → 422
-GET /api/v1/vehicles/:id/files/:fileId → 200 + file content (download)
-DELETE /api/v1/vehicles/:id/files/:fileId → 200 + file deleted
-File gespeichert in uploads_data volume, path in DB gespeichert
-Image files → Thumbnail generiert (200x200)
-Frontend File Upload akzeptiert Drag-and-Drop multi-file
-Frontend File List zeigt Thumbnails für images
-Frontend File Delete zeigt Confirm Dialog
-Frontend Gallery view für Fahrzeug-Bilder
-pytest coverage >= 80% für file module (82%)
- ✅ GET /api/v1/sales → 200 + paginated list with filter (date range, status)
-GET /api/v1/sales/:id → 200 + sale detail with vehicle + contact nested
- ✅ POST /api/v1/sales mit valid data → 201 + sale object
- ✅ POST /api/v1/sales ohne vehicle_id → 422
-POST /api/v1/sales ohne buyer_contact_id → 422
-PUT /api/v1/sales/:id → 200 + updated sale
-DELETE /api/v1/sales/:id → 200 (sale cancelled, vehicle status back to available)
-POST /api/v1/sales/:id/contract → 200 + regenerated contract PDF path
-GET /api/v1/sales/:id/contract → 200 + PDF content (application/pdf)
-Sale mit is_gwg=true und sale_price <= 800 → GwG-Klausel in contract
-POST /api/v1/sales/:id/verify-ust-id → 200 + {verified: false, message: 'BZSt API not available'}
-POST /api/v1/datev/export mit date range → 201 + DATEVExport object
-POST /api/v1/datev/export mit invalid date range → 422
- ✅ GET /api/v1/datev/exports → 200 + list of exports
- ✅ GET /api/v1/datev/exports/:id/download → 200 + CSV content (text/csv)
- ✅ DATEV CSV enthält korrekte Felder: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
- ✅ Sale creation → Vehicle status='sold'
- ✅ Sale deletion → Vehicle status='available' (in_stock)
+134 -24
View File
@@ -12,7 +12,16 @@
"description": "Komplettes Auth-System: JWT login/refresh, User CRUD (admin only), RBAC Middleware (admin/verkaeufer/buchhaltung). Backend: User Model, auth_service, auth router, users router, JWT utils, RBAC dependency. Frontend: Root layout, Login page, Auth context/hooks, i18n setup (next-intl oder react-i18next), de.json + en.json Basistranslationen, API client (fetch wrapper mit Auth-Header), Base UI components (Button, Input, Card, Table, Modal, Toast). Health endpoint /api/health. Config via Pydantic BaseSettings (DB URL, Redis URL, JWT Secret, OpenRouter Key, mobile.de credentials).",
"assigned_subagent": "implementation_engineer",
"dependencies": [],
"requirement_ids": ["M6-F1", "M6-F2", "AUTH-1", "AUTH-2", "AUTH-3", "AUTH-4", "I18N-1", "I18N-2"],
"requirement_ids": [
"M6-F1",
"M6-F2",
"AUTH-1",
"AUTH-2",
"AUTH-3",
"AUTH-4",
"I18N-1",
"I18N-2"
],
"estimated_effort": "L",
"estimated_lines": 600,
"acceptance_criteria": [
@@ -88,8 +97,18 @@
"category": "vehicle",
"description": "Komplettes Fahrzeug-Modul: Vehicle Model (LKW, Baumaschine, PKW, Stapler, Transporter), vehicle_service mit CRUD + filter/sort/paginate, vehicles router mit allen Endpoints. mobile.de Seller API Integration (Push only): mobilede_service, mobilede_push background task, Ad-Format Mapping, Retry-Queue via Redis. Frontend: Vehicle List page mit Filter (type, status, brand) + Pagination + Sort, Vehicle Detail page, Vehicle Create/Edit form, mobile.de Push Button + Status indicator. Soft-Delete Implementierung (status='deleted').",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T01"],
"requirement_ids": ["M1-F1", "M1-F2", "M1-F3", "M1-F4", "M1-F5", "M1-F6", "M1-F7"],
"dependencies": [
"T01"
],
"requirement_ids": [
"M1-F1",
"M1-F2",
"M1-F3",
"M1-F4",
"M1-F5",
"M1-F6",
"M1-F7"
],
"estimated_effort": "L",
"estimated_lines": 700,
"acceptance_criteria": [
@@ -152,8 +171,17 @@
"status": "completed",
"description": "Komplettes OCR-Modul: OCRResult Model, ocr_service mit OpenRouter Qwen2.5-VL Integration, OCR router (upload, get results, apply to vehicle). Async OCR processing via Redis Queue. Prompt-Engineering für ZB I/II: structured JSON output (brand, model, vin, first_registration, mileage, power_kw, fuel_type). Confidence-Score Berechnung. Manual Review bei confidence < 0.7. Response-Caching für identische Scans. Frontend: OCR Upload page (drag-and-drop), OCR Results view mit side-by-side Original Scan + Extracted Data, Apply-to-Vehicle Button.",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T01", "T02"],
"requirement_ids": ["M2-F1", "M2-F2", "M2-F3", "M2-F4", "M2-F5"],
"dependencies": [
"T01",
"T02"
],
"requirement_ids": [
"M2-F1",
"M2-F2",
"M2-F3",
"M2-F4",
"M2-F5"
],
"estimated_effort": "L",
"estimated_lines": 600,
"acceptance_criteria": [
@@ -205,8 +233,17 @@
"category": "contact",
"description": "Komplettes Kontakt-Modul: Contact Model (Kunde/Lieferant/Beide, EU/Inland, USt-IdNr.), contact_service mit CRUD + search + filter (type, eu/inland), contacts router. USt-IdNr.-Feld mit Format-Validierung (DE + EU Formate). Contact-Search (name, company, city, email). Frontend: Contact List mit Search + Filter, Contact Detail page, Contact Create/Edit form. EU/Inland Toggle. USt-IdNr. input mit Format-Validierung frontend-seitig.",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T01"],
"requirement_ids": ["M3-F1", "M3-F2", "M3-F3", "M3-F4", "M3-F5", "M3-F6"],
"dependencies": [
"T01"
],
"requirement_ids": [
"M3-F1",
"M3-F2",
"M3-F3",
"M3-F4",
"M3-F5",
"M3-F6"
],
"estimated_effort": "M",
"estimated_lines": 500,
"acceptance_criteria": [
@@ -260,8 +297,17 @@
"category": "file",
"description": "Komplettes Dateiablage-Modul: File Model, file_service mit Upload/Download/Delete, files router. MIME-Type-Validierung (images: jpg/png/webp, documents: pdf/doc/docx, scans: jpg/png). Max 20MB pro Datei. Dateien gespeichert im uploads_data Volume. Thumbnail-Generierung für Images. Frontend: File Upload Component (Drag-and-Drop, multi-file), File List mit Thumbnails, File Download, File Delete mit Confirm. Gallery view für Fahrzeug-Bilder.",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T01", "T02"],
"requirement_ids": ["M4-F1", "M4-F2", "M4-F3", "M4-F4", "M4-F5"],
"dependencies": [
"T01",
"T02"
],
"requirement_ids": [
"M4-F1",
"M4-F2",
"M4-F3",
"M4-F4",
"M4-F5"
],
"estimated_effort": "M",
"estimated_lines": 500,
"acceptance_criteria": [
@@ -311,8 +357,21 @@
"category": "sales",
"description": "Komplettes Verkaufsmodul: Sale Model, sale_service mit CRUD, sales router. Contract PDF Generierung (Verkaufvertrag mit Vehicle + Contact Daten, GwG-Klausel, USt-IdNr. Feld). DATEV-Export: datev_service, DATEV CSV Format (Buchungsstapel: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext). GwG-Logik: Sofortabzug bei <= 800EUR (§6 Abs. 2 EStG). USt-IdNr.-Prüfung: BZSt API endpoint (Feature-Flag bzst_api_enabled, aktuell disabled → manual review). DATEVExport Model + router. Frontend: Sales List, Sale Detail mit Contract Preview, Sale Create form (Vehicle select, Contact select, Price, GwG toggle), DATEV Export page (Zeitraum wählen, Download CSV).",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T02", "T04"],
"requirement_ids": ["M5-F1", "M5-F2", "M5-F3", "M5-F4", "M5-F5", "M5-F6", "M5-F7", "M5-F8", "M5-F9"],
"dependencies": [
"T02",
"T04"
],
"requirement_ids": [
"M5-F1",
"M5-F2",
"M5-F3",
"M5-F4",
"M5-F5",
"M5-F6",
"M5-F7",
"M5-F8",
"M5-F9"
],
"estimated_effort": "XL",
"estimated_lines": 800,
"acceptance_criteria": [
@@ -376,7 +435,8 @@
"frontend/components/sales/DatevExport.tsx",
"frontend/tests/sales.test.tsx",
"frontend/tests/datev.test.tsx"
]
],
"status": "completed"
},
{
"id": "T07",
@@ -384,8 +444,19 @@
"category": "copilot",
"description": "Komplettes KI-Copilot-Modul: Copilot chat_service mit OpenRouter (Claude/GPT-4), copilot router (chat, voice, history, action). System-Prompt mit ERP-Kontext (verfügbare Aktionen: vehicle search/create, contact search, sale overview). Action-System: Copilot kann strukturierte Actions ausführen (GET /api/vehicles, POST /api/contacts, etc.) via function-calling. Voice Input: STT via Browser Web Speech API → text → chat. Chat History (paginated, pro User). Frontend: Copilot Chat Interface (message list, input, send), Voice Input Button (microphone), Action Preview (zeigt was Copilot tun will vor Ausführung), Chat History sidebar.",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T01", "T02"],
"requirement_ids": ["M7-F1", "M7-F2", "M7-F3", "M7-F4", "M7-F5", "M7-F6", "M7-F7"],
"dependencies": [
"T01",
"T02"
],
"requirement_ids": [
"M7-F1",
"M7-F2",
"M7-F3",
"M7-F4",
"M7-F5",
"M7-F6",
"M7-F7"
],
"estimated_effort": "L",
"estimated_lines": 700,
"acceptance_criteria": [
@@ -441,8 +512,18 @@
"category": "retouch",
"description": "Komplettes Bildretusche-Modul: Retouch service mit OpenRouter Flux.1-Pro, retouch router (process, get result, price-compare). Bild-Verarbeitung: Upload → Flux.1-Pro Retusche (Hintergrund entfernen/verbessern, Spiegelungen, Farbkorrektur) → retouched image gespeichert. Preisvergleich: mobile.de Listings Scraper/API → comparable vehicles (brand, model, year, price) → Durchschnittspreis berechnet. Frontend: Retouch Upload page (Bild wählen, Retusche starten), Before/After Vergleich (slider), Preisvergleich view (Tabelle mit comparable listings + Durchschnittspreis).",
"assigned_subagent": "implementation_engineer",
"dependencies": ["T01", "T02", "T05"],
"requirement_ids": ["M8-F1", "M8-F2", "M8-F3", "M8-F4", "M8-F5"],
"dependencies": [
"T01",
"T02",
"T05"
],
"requirement_ids": [
"M8-F1",
"M8-F2",
"M8-F3",
"M8-F4",
"M8-F5"
],
"estimated_effort": "L",
"estimated_lines": 600,
"acceptance_criteria": [
@@ -490,15 +571,44 @@
],
"dependency_graph": {
"T01": [],
"T02": ["T01"],
"T03": ["T01", "T02"],
"T04": ["T01"],
"T05": ["T01", "T02"],
"T06": ["T02", "T04"],
"T07": ["T01", "T02"],
"T08": ["T01", "T02", "T05"]
"T02": [
"T01"
],
"T03": [
"T01",
"T02"
],
"T04": [
"T01"
],
"T05": [
"T01",
"T02"
],
"T06": [
"T02",
"T04"
],
"T07": [
"T01",
"T02"
],
"T08": [
"T01",
"T02",
"T05"
]
},
"execution_order": ["T01", "T02", "T04", "T03", "T05", "T06", "T07", "T08"],
"execution_order": [
"T01",
"T02",
"T04",
"T03",
"T05",
"T06",
"T07",
"T08"
],
"summary": {
"total_tasks": 8,
"tasks_with_test_spec": 8,
+43 -26
View File
@@ -1,38 +1,55 @@
# Worklog
## T05: Dateiablage pro Fahrzeug + File UI - 2026-07-17
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
**Date:** 2026-07-17
**Status:** COMPLETED
### Files Created
**Backend:**
- backend/app/models/file.py - File SQLAlchemy model (UUID, vehicle_id FK, filename, path, mime_type, size, thumbnail_path)
- backend/app/schemas/file.py - Pydantic schemas (FileUploadResponse, FileResponse, FileListResponse, FileDeleteResponse)
- backend/app/services/file_service.py - File service (upload, get, list, delete, validate_mime_type, validate_file_size)
- backend/app/utils/thumbnails.py - Thumbnail generation (200x200) using Pillow for jpg/png/webp
- backend/app/routers/files.py - Files router (GET list, POST upload, GET download, DELETE)
- backend/tests/test_files.py - 43 tests covering all endpoints, validation, thumbnails, service unit tests
#### Backend
- backend/app/models/sale.py - Sale model with UUID PK, vehicle/buyer/seller FKs, sale_price, status, is_gwg, contract_pdf_path
- backend/app/models/datev_export.py - DATEVExport model with start_date, end_date, file_path, total_amount
- backend/app/schemas/sale.py - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse
- backend/app/schemas/datev.py - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse
- backend/app/services/sale_service.py - Full CRUD + contract PDF + GwG logic + USt-IdNr. verification
- backend/app/services/datev_service.py - Export creation, listing, CSV download
- backend/app/utils/contract_pdf.py - HTML template with WeasyPrint, GwG-Klausel §6 Abs. 2 EStG, USt-IdNr. field
- backend/app/utils/datev.py - DATEV CSV Buchungsstapel format helper
- backend/app/routers/sales.py - Sales CRUD + contract + USt-IdNr. endpoints
- backend/app/routers/datev.py - DATEV export + list + download endpoints
- backend/tests/test_sales.py - 20 tests covering CRUD, contract PDF, GwG, vehicle status, USt-IdNr.
- backend/tests/test_datev.py - 14 tests covering export API, CSV format, validation
**Frontend:**
- frontend/lib/files.ts - API functions, types, utilities (listFiles, uploadFile, deleteFile, formatFileSize, isImageMime)
- frontend/components/files/FileUpload.tsx - Drag-and-drop multi-file upload with validation
- frontend/components/files/FileList.tsx - File list with thumbnails, download, delete confirm dialog
- frontend/components/files/FileGallery.tsx - Gallery view filtering images only
- frontend/components/files/FilePreview.tsx - File preview modal with metadata and download
- frontend/tests/files.test.tsx - 25 tests covering all components and utilities
#### Frontend
- frontend/lib/sales.ts - Sales API client functions and types
- frontend/lib/datev.ts - DATEV API client functions and types
- frontend/components/sales/SaleList.tsx - Sales table with filters and pagination
- frontend/components/sales/SaleForm.tsx - Sale create form with GwG toggle
- frontend/components/sales/ContractPreview.tsx - PDF preview with regenerate/download
- frontend/components/sales/DatevExport.tsx - DATEV export page with date range picker
- frontend/app/[locale]/verkauf/page.tsx - Sales list page
- frontend/app/[locale]/verkauf/[id]/page.tsx - Sale detail with contract preview
- frontend/app/[locale]/verkauf/neu/page.tsx - Sale create page
- frontend/tests/sales.test.tsx - 11 frontend tests
- frontend/tests/datev.test.tsx - 5 frontend tests
### Files Modified
- backend/app/models/vehicle.py - Added files relationship to Vehicle model
- backend/app/main.py - Registered files router
- backend/requirements.txt - Added Pillow>=10.0.0
- backend/app/config.py - Added BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR
- backend/app/main.py - Registered sales + datev routers
- backend/app/models/__init__.py - Added sale + datev_export imports for Base.metadata registration
- backend/requirements.txt - Added weasyprint>=62.0
### Test Results
- Backend: 43/43 passed, 82% coverage (file_service 95%, thumbnails 88%, files router 55%)
- Frontend: 25/25 passed
- TypeScript: tsc --noEmit passes with no errors
- Backend: 34/34 passed (11.42s)
- Frontend: 16/16 passed (1.85s)
- Total: 50/50 passed
### Smoke Test
- Backend API endpoints fully functional with real PostgreSQL (upload, list, download, delete)
- Thumbnail generation verified for JPEG, PNG, WebP (200x200)
- MIME validation enforces jpg/png/webp/pdf/doc/docx only
- File size limit enforced at 20MB
- Frontend drag-and-drop upload works, delete confirmation dialog works, gallery filters images
- Backend: All endpoints respond correctly (200/201/404/422)
- GwG logic: Clause appears when is_gwg=true AND price <= 800 EUR (§6 Abs. 2 EStG)
- Vehicle status: Sale creation → 'sold', sale cancel → 'available'
- DATEV CSV: Correct headers, comma decimal separator, DD.MM.YYYY date format
- USt-IdNr. verify: Returns 'BZSt API not available' when feature flag disabled
- WeasyPrint: Installed, async PDF generation via asyncio.to_thread
- Frontend: All components render with mocked API, test IDs present
BIN
View File
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
Collecting weasyprint
Downloading weasyprint-69.0-py3-none-any.whl.metadata (3.8 kB)
Collecting pydyf>=0.11.0 (from weasyprint)
Downloading pydyf-0.12.1-py3-none-any.whl.metadata (2.5 kB)
Requirement already satisfied: cffi>=0.6 in /opt/venv/lib/python3.13/site-packages (from weasyprint) (2.1.0)
Collecting tinyhtml5>=2.0.0b1 (from weasyprint)
Downloading tinyhtml5-2.1.0-py3-none-any.whl.metadata (2.9 kB)
Collecting tinycss2>=1.5.0 (from weasyprint)
Downloading tinycss2-1.5.1-py3-none-any.whl.metadata (3.0 kB)
Collecting cssselect2>=0.8.0 (from weasyprint)
Downloading cssselect2-0.9.0-py3-none-any.whl.metadata (2.9 kB)
Collecting Pyphen>=0.9.1 (from weasyprint)
Downloading pyphen-0.17.2-py3-none-any.whl.metadata (3.2 kB)
Requirement already satisfied: Pillow>=9.1.0 in /opt/venv/lib/python3.13/site-packages (from weasyprint) (12.3.0)
Collecting fonttools>=4.59.2 (from fonttools[woff]>=4.59.2->weasyprint)
Downloading fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (118 kB)
Requirement already satisfied: pycparser in /opt/venv/lib/python3.13/site-packages (from cffi>=0.6->weasyprint) (3.0)
Collecting webencodings (from cssselect2>=0.8.0->weasyprint)
Downloading webencodings-0.5.1-py2.py3-none-any.whl.metadata (2.1 kB)
Collecting brotli>=1.0.1 (from fonttools[woff]>=4.59.2->weasyprint)
Downloading brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (6.1 kB)
Collecting zopfli>=0.1.4 (from fonttools[woff]>=4.59.2->weasyprint)
Downloading zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (3.5 kB)
Downloading weasyprint-69.0-py3-none-any.whl (322 kB)
Downloading cssselect2-0.9.0-py3-none-any.whl (15 kB)
Downloading fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (5.0 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.0/5.0 MB 141.6 MB/s 0:00:00
Downloading brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (1.4 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.4/1.4 MB 68.0 MB/s 0:00:00
Downloading pydyf-0.12.1-py3-none-any.whl (8.0 kB)
Downloading pyphen-0.17.2-py3-none-any.whl (2.1 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.1/2.1 MB 162.7 MB/s 0:00:00
Downloading tinycss2-1.5.1-py3-none-any.whl (28 kB)
Downloading tinyhtml5-2.1.0-py3-none-any.whl (39 kB)
Downloading webencodings-0.5.1-py2.py3-none-any.whl (11 kB)
Downloading zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (829 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 829.4/829.4 kB 71.9 MB/s 0:00:00
Installing collected packages: webencodings, brotli, zopfli, tinyhtml5, tinycss2, Pyphen, pydyf, fonttools, cssselect2, weasyprint
Successfully installed Pyphen-0.17.2 brotli-1.2.0 cssselect2-0.9.0 fonttools-4.63.0 pydyf-0.12.1 tinycss2-1.5.1 tinyhtml5-2.1.0 weasyprint-69.0 webencodings-0.5.1 zopfli-0.4.3
[notice] A new release of pip is available: 26.0.1 -> 26.1.2
[notice] To update, run: pip install --upgrade pip
+2
View File
@@ -37,6 +37,8 @@ class Settings(BaseSettings):
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
MAX_FILE_SIZE_MB: int = 50
BZST_API_ENABLED: bool = False
WEASYPRINT_OUTPUT_DIR: str = "/tmp/contracts"
@property
def cors_origins_list(self) -> list[str]:
+3 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, contacts, files, ocr, users, vehicles
from app.routers import auth, contacts, datev, files, ocr, sales, users, vehicles
@asynccontextmanager
@@ -44,6 +44,8 @@ api_v1_router.include_router(vehicles.router)
api_v1_router.include_router(contacts.router)
api_v1_router.include_router(files.router)
api_v1_router.include_router(ocr.router)
api_v1_router.include_router(sales.router)
api_v1_router.include_router(datev.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
+27
View File
@@ -0,0 +1,27 @@
"""SQLAlchemy models package.
Import all models here so they register with Base.metadata
before create_all/drop_all is called.
"""
from app.models.contact import Contact, ContactPerson
from app.models.datev_export import DATEVExport
from app.models.file import File
from app.models.ocr_result import OCRResult
from app.models.sale import Sale, SaleStatus
from app.models.user import User, UserRole
from app.models.vehicle import MobileDeListing, Vehicle
__all__ = [
"Contact",
"ContactPerson",
"DATEVExport",
"File",
"OCRResult",
"Sale",
"SaleStatus",
"User",
"UserRole",
"Vehicle",
"MobileDeListing",
]
+50
View File
@@ -0,0 +1,50 @@
"""SQLAlchemy model for DATEV export records."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, Numeric, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DATEVExport(Base):
"""DATEV export record tracking CSV exports for accounting."""
__tablename__ = "datev_exports"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
file_path: Mapped[str | None] = mapped_column(
String(500), nullable=True
)
total_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, default=0
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def __repr__(self) -> str:
return f"<DATEVExport id={self.id} start={self.start_date} end={self.end_date}>"
def to_dict(self) -> dict:
"""Serialize DATEV export for API responses."""
return {
"id": str(self.id),
"start_date": self.start_date.isoformat() if self.start_date else None,
"end_date": self.end_date.isoformat() if self.end_date else None,
"file_path": self.file_path,
"total_amount": (
float(self.total_amount) if self.total_amount is not None else None
),
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+120
View File
@@ -0,0 +1,120 @@
"""SQLAlchemy model for sales (Verkauf)."""
import enum
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Numeric,
String,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SaleStatus(str, enum.Enum):
draft = "draft"
completed = "completed"
cancelled = "cancelled"
class Sale(Base):
"""Sale entity linking a vehicle to a buyer (and optionally a seller)."""
__tablename__ = "sales"
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'completed', 'cancelled')",
name="ck_sales_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("vehicles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
buyer_contact_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
seller_contact_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("contacts.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
sale_price: Mapped[Decimal] = mapped_column(
Numeric(12, 2), nullable=False
)
sale_date: Mapped[date] = mapped_column(
Date, nullable=False, default=func.current_date()
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="draft"
)
is_gwg: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
contract_pdf_path: Mapped[str | None] = mapped_column(
String(500), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
vehicle: Mapped["Vehicle"] = relationship(lazy="selectin")
buyer: Mapped["Contact"] = relationship(
"Contact",
foreign_keys=[buyer_contact_id],
lazy="selectin",
)
seller: Mapped["Contact | None"] = relationship(
"Contact",
foreign_keys=[seller_contact_id],
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Sale id={self.id} vehicle_id={self.vehicle_id} status={self.status}>"
def to_dict(self) -> dict:
"""Serialize sale for API responses."""
return {
"id": str(self.id),
"vehicle_id": str(self.vehicle_id),
"buyer_contact_id": str(self.buyer_contact_id),
"seller_contact_id": (
str(self.seller_contact_id) if self.seller_contact_id else None
),
"sale_price": float(self.sale_price) if self.sale_price is not None else None,
"sale_date": self.sale_date.isoformat() if self.sale_date else None,
"status": self.status,
"is_gwg": self.is_gwg,
"contract_pdf_path": self.contract_pdf_path,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+78
View File
@@ -0,0 +1,78 @@
"""DATEV export router: create exports, list exports, download CSV."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.datev import (
DATEVExportCreate,
DATEVExportListResponse,
DATEVExportResponse,
)
from app.services import datev_service
router = APIRouter(prefix="/datev", tags=["datev"])
@router.post("/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED)
async def create_export(
body: DATEVExportCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a DATEV export for the given date range.
Queries all completed sales within the date range and generates a CSV file.
"""
export = await datev_service.create_export(
db,
start_date=body.start_date,
end_date=body.end_date,
)
return DATEVExportResponse.model_validate(export)
@router.get("/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK)
async def list_exports(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
current_user: User = Depends(get_current_user),
):
"""List all DATEV exports with pagination."""
exports, total = await datev_service.list_exports(
db,
page=pagination["page"],
page_size=pagination["page_size"],
)
return DATEVExportListResponse(
items=[DATEVExportResponse.model_validate(e) for e in exports],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.get("/exports/{export_id}/download", status_code=status.HTTP_200_OK)
async def download_export(
export_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download a DATEV export as CSV file."""
result = await datev_service.get_export_csv(db, export_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "EXPORT_NOT_FOUND", "message": "DATEV export not found"}},
)
filename, csv_bytes = result
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+203
View File
@@ -0,0 +1,203 @@
"""Sales router: CRUD, contract PDF, USt-IdNr. verification."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.sale import (
ContractResponse,
SaleCreate,
SaleListResponse,
SaleResponse,
SaleUpdate,
UstIdVerifyResponse,
)
from app.services import sale_service
router = APIRouter(prefix="/sales", tags=["sales"])
@router.get("/", response_model=SaleListResponse, status_code=status.HTTP_200_OK)
async def list_sales(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
status_filter: str | None = Query(None, alias="status", description="Filter by sale status"),
date_from: str | None = Query(None, description="Filter sales from this date (YYYY-MM-DD)"),
date_to: str | None = Query(None, description="Filter sales up to this date (YYYY-MM-DD)"),
current_user: User = Depends(get_current_user),
):
"""List sales with pagination, filtering by status and date range."""
from datetime import date as date_type
parsed_date_from = None
parsed_date_to = None
if date_from:
try:
parsed_date_from = date_type.fromisoformat(date_from)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_from format: {date_from}"}},
)
if date_to:
try:
parsed_date_to = date_type.fromisoformat(date_to)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_to format: {date_to}"}},
)
sales, total = await sale_service.list_sales(
db,
page=pagination["page"],
page_size=pagination["page_size"],
status=status_filter,
date_from=parsed_date_from,
date_to=parsed_date_to,
)
return SaleListResponse(
items=[SaleResponse.model_validate(s) for s in sales],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/", response_model=SaleResponse, status_code=status.HTTP_201_CREATED)
async def create_sale(
body: SaleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new sale. Sets vehicle status to 'sold'."""
data = body.model_dump(exclude_unset=False)
try:
sale = await sale_service.create_sale(db, data)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
)
return SaleResponse.model_validate(sale)
@router.get("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def get_sale(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single sale by ID with nested vehicle and contacts."""
sale = await sale_service.get_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.put("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def update_sale(
sale_id: uuid.UUID,
body: SaleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a sale's fields."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
)
sale = await sale_service.update_sale(db, sale_id, updates)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.delete("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def delete_sale(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel a sale (soft delete). Sets sale status to 'cancelled' and restores vehicle status to 'available'."""
sale = await sale_service.delete_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.post("/{sale_id}/contract", response_model=ContractResponse, status_code=status.HTTP_200_OK)
async def regenerate_contract(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Regenerate the contract PDF for a sale."""
sale = await sale_service.regenerate_contract_pdf(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return ContractResponse(
sale_id=sale.id,
contract_pdf_path=sale.contract_pdf_path or "",
message="Contract regenerated",
)
@router.get("/{sale_id}/contract", status_code=status.HTTP_200_OK)
async def download_contract(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download the contract PDF for a sale."""
sale = await sale_service.get_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTRACT_NOT_FOUND", "message": "Contract PDF not generated yet"}},
)
return FileResponse(
path=sale.contract_pdf_path,
media_type="application/pdf",
filename=f"contract_{sale.id}.pdf",
)
@router.post("/{sale_id}/verify-ust-id", response_model=UstIdVerifyResponse, status_code=status.HTTP_200_OK)
async def verify_ust_id(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Verify the buyer's USt-IdNr. via BZSt API.
Feature flag: BZST_API_ENABLED (default: False).
When disabled, returns {verified: false, message: 'BZSt API not available'}.
"""
result = await sale_service.verify_ust_id(sale_id, db)
return UstIdVerifyResponse(**result)
+44
View File
@@ -0,0 +1,44 @@
"""Pydantic schemas for DATEV export request and response bodies."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
class DATEVExportCreate(BaseModel):
"""POST /api/v1/datev/export request body."""
start_date: date = Field(..., description="Start date of the export range")
end_date: date = Field(..., description="End date of the export range (inclusive)")
@model_validator(mode="after")
def validate_date_range(self) -> "DATEVExportCreate":
"""Ensure start_date is not after end_date."""
if self.start_date > self.end_date:
raise ValueError("start_date must not be after end_date")
return self
class DATEVExportResponse(BaseModel):
"""DATEV export response schema."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
start_date: date
end_date: date
file_path: Optional[str] = None
total_amount: Decimal
created_at: Optional[datetime] = None
class DATEVExportListResponse(BaseModel):
"""Paginated DATEV export list response."""
items: list[DATEVExportResponse]
total: int
page: int
page_size: int
+107
View File
@@ -0,0 +1,107 @@
"""Pydantic schemas for sale-related request and response bodies."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
SALE_STATUSES = Literal["draft", "completed", "cancelled"]
class SaleCreate(BaseModel):
"""POST /api/v1/sales request body."""
vehicle_id: uuid.UUID
buyer_contact_id: uuid.UUID
seller_contact_id: Optional[uuid.UUID] = None
sale_price: Decimal = Field(..., ge=0)
sale_date: Optional[date] = None
status: SALE_STATUSES = "draft"
is_gwg: bool = False
class SaleUpdate(BaseModel):
"""PUT /api/v1/sales/:id request body (all fields optional)."""
sale_price: Optional[Decimal] = Field(None, ge=0)
sale_date: Optional[date] = None
status: Optional[SALE_STATUSES] = None
is_gwg: Optional[bool] = None
seller_contact_id: Optional[uuid.UUID] = None
class VehicleNested(BaseModel):
"""Nested vehicle info in sale response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
make: str
model: str
fin: str
vehicle_type: str
availability: str
price: Decimal
class ContactNested(BaseModel):
"""Nested contact info in sale response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
company_name: str
address_city: Optional[str] = None
address_country: str
vat_id: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
class SaleResponse(BaseModel):
"""Sale response schema with nested vehicle and contacts."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
vehicle_id: uuid.UUID
buyer_contact_id: uuid.UUID
seller_contact_id: Optional[uuid.UUID] = None
sale_price: Decimal
sale_date: date
status: str
is_gwg: bool
contract_pdf_path: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
vehicle: Optional[VehicleNested] = None
buyer: Optional[ContactNested] = None
seller: Optional[ContactNested] = None
class SaleListResponse(BaseModel):
"""Paginated sale list response."""
items: list[SaleResponse]
total: int
page: int
page_size: int
class ContractResponse(BaseModel):
"""Response for contract PDF generation/download."""
sale_id: uuid.UUID
contract_pdf_path: str
message: str = "Contract generated"
class UstIdVerifyResponse(BaseModel):
"""Response for USt-IdNr. verification."""
verified: bool
message: str
vat_id: Optional[str] = None
+154
View File
@@ -0,0 +1,154 @@
"""DATEV export service: create exports, list exports, generate CSV."""
from __future__ import annotations
import os
import uuid
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.datev_export import DATEVExport
from app.models.sale import Sale
from app.utils.datev import generate_datev_csv, validate_datev_csv
async def create_export(
db: AsyncSession,
start_date: date,
end_date: date,
output_dir: str = "/tmp/datev_exports",
) -> DATEVExport:
"""Create a DATEV export for the given date range.
Queries all completed sales within the date range, generates a CSV file,
and stores the export record.
Args:
db: Async session.
start_date: Start of the date range (inclusive).
end_date: End of the date range (inclusive).
output_dir: Directory to save the CSV file.
Returns:
DATEVExport model instance.
"""
# Query completed sales within date range
stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.where(
and_(
Sale.sale_date >= start_date,
Sale.sale_date <= end_date,
Sale.status == "completed",
)
)
.order_by(Sale.sale_date.asc())
)
result = await db.execute(stmt)
sales = list(result.scalars().all())
# Generate CSV content
csv_content = generate_datev_csv(sales)
# Calculate total amount
total_amount = sum(
(s.sale_price or Decimal("0")) for s in sales
)
# Save CSV file
os.makedirs(output_dir, exist_ok=True)
export_id = uuid.uuid4()
file_path = os.path.join(output_dir, f"datev_export_{export_id}.csv")
with open(file_path, "w", encoding="utf-8") as f:
f.write(csv_content)
# Create export record
export = DATEVExport(
id=export_id,
start_date=start_date,
end_date=end_date,
file_path=file_path,
total_amount=total_amount,
)
db.add(export)
await db.flush()
await db.refresh(export)
return export
async def list_exports(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
) -> tuple[list[DATEVExport], int]:
"""List DATEV exports with pagination.
Returns (exports, total_count).
"""
count_stmt = select(func.count(DATEVExport.id))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
data_stmt = (
select(DATEVExport)
.order_by(DATEVExport.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(data_stmt)
exports = list(result.scalars().all())
return exports, total
async def get_export_csv(db: AsyncSession, export_id: uuid.UUID) -> tuple[str, bytes] | None:
"""Get the CSV content for a DATEV export.
Args:
db: Async session.
export_id: Export ID.
Returns:
Tuple of (filename, csv_bytes) or None if export not found.
"""
stmt = select(DATEVExport).where(DATEVExport.id == export_id)
result = await db.execute(stmt)
export = result.scalar_one_or_none()
if export is None:
return None
if export.file_path and os.path.exists(export.file_path):
with open(export.file_path, "r", encoding="utf-8") as f:
csv_content = f.read()
else:
# Regenerate from sales if file is missing
sales_stmt = (
select(Sale)
.options(selectinload(Sale.vehicle))
.where(
and_(
Sale.sale_date >= export.start_date,
Sale.sale_date <= export.end_date,
Sale.status == "completed",
)
)
.order_by(Sale.sale_date.asc())
)
sales_result = await db.execute(sales_stmt)
sales = list(sales_result.scalars().all())
csv_content = generate_datev_csv(sales)
filename = f"datev_export_{export.id}.csv"
return filename, csv_content.encode("utf-8")
+242
View File
@@ -0,0 +1,242 @@
"""Sale service: CRUD, contract PDF generation, GwG logic, USt-IdNr. verification."""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.models.sale import Sale
from app.models.vehicle import Vehicle
from app.utils.contract_pdf import generate_contract_pdf
async def create_sale(db: AsyncSession, data: dict[str, Any]) -> Sale:
"""Create a new sale record.
Sets vehicle availability to 'sold' upon creation.
Generates contract PDF if status is 'completed'.
Raises ValueError if vehicle or buyer contact not found.
"""
vehicle_id = data["vehicle_id"]
buyer_contact_id = data["buyer_contact_id"]
# Verify vehicle exists
vehicle = await db.get(Vehicle, vehicle_id)
if vehicle is None or vehicle.deleted_at is not None:
raise ValueError(f"Vehicle {vehicle_id} not found")
# Set sale_date to today if not provided
if data.get("sale_date") is None:
data["sale_date"] = date.today()
sale = Sale(**data)
db.add(sale)
await db.flush()
# Update vehicle status to sold
vehicle.availability = "sold"
await db.flush()
# Load nested relationships for response
await db.refresh(sale)
# Explicitly load relationships
stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.where(Sale.id == sale.id)
)
result = await db.execute(stmt)
sale = result.scalar_one()
# Generate contract PDF if status is completed
if sale.status == "completed":
pdf_path = await generate_contract_pdf(sale)
sale.contract_pdf_path = pdf_path
await db.flush()
return sale
async def get_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Get a single sale by ID with nested relationships."""
stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.where(Sale.id == sale_id)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def list_sales(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
status: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
) -> tuple[list[Sale], int]:
"""List sales with pagination and optional filtering by status and date range.
Returns (sales, total_count).
"""
conditions = []
if status:
conditions.append(Sale.status == status)
if date_from:
conditions.append(Sale.sale_date >= date_from)
if date_to:
conditions.append(Sale.sale_date <= date_to)
# Count query
count_stmt = select(func.count(Sale.id))
if conditions:
count_stmt = count_stmt.where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# Data query with eager loading
data_stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.order_by(Sale.created_at.desc())
)
if conditions:
data_stmt = data_stmt.where(and_(*conditions))
offset = (page - 1) * page_size
data_stmt = data_stmt.offset(offset).limit(page_size)
result = await db.execute(data_stmt)
sales = list(result.scalars().all())
return sales, total
async def update_sale(
db: AsyncSession, sale_id: uuid.UUID, updates: dict[str, Any]
) -> Sale | None:
"""Update a sale's fields. Returns None if not found."""
sale = await get_sale(db, sale_id)
if sale is None:
return None
for key, value in updates.items():
if hasattr(sale, key):
setattr(sale, key, value)
await db.flush()
# Reload with relationships
sale = await get_sale(db, sale_id)
return sale
async def cancel_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Cancel a sale: set status to 'cancelled' and restore vehicle status to 'available'.
Returns None if sale not found.
"""
sale = await get_sale(db, sale_id)
if sale is None:
return None
sale.status = "cancelled"
# Restore vehicle availability
vehicle = await db.get(Vehicle, sale.vehicle_id)
if vehicle is not None:
vehicle.availability = "available"
await db.flush()
sale = await get_sale(db, sale_id)
return sale
async def delete_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Delete a sale (cancel + restore vehicle status to 'in_stock').
This is a soft-cancel: sale status is set to 'cancelled' and
vehicle availability is restored to 'available' (in_stock).
Returns None if sale not found.
"""
return await cancel_sale(db, sale_id)
async def regenerate_contract_pdf(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Regenerate the contract PDF for a sale.
Returns the updated sale with new contract_pdf_path, or None if not found.
"""
sale = await get_sale(db, sale_id)
if sale is None:
return None
pdf_path = await generate_contract_pdf(sale)
sale.contract_pdf_path = pdf_path
await db.flush()
sale = await get_sale(db, sale_id)
return sale
async def verify_ust_id(sale_id: uuid.UUID, db: AsyncSession) -> dict[str, Any]:
"""Verify the buyer's USt-IdNr. via BZSt API.
Feature flag: bzst_api_enabled (default: False).
When disabled, returns {verified: false, message: 'BZSt API not available'}.
Args:
sale_id: Sale ID to look up the buyer contact.
db: Async session.
Returns:
Dict with verified, message, and vat_id fields.
"""
# Check feature flag
bzst_enabled = getattr(settings, "BZST_API_ENABLED", False)
if not bzst_enabled:
return {
"verified": False,
"message": "BZSt API not available",
"vat_id": None,
}
# If enabled, we would call the BZSt API here
# For now, return manual review message
sale = await get_sale(db, sale_id)
if sale is None:
return {
"verified": False,
"message": "Sale not found",
"vat_id": None,
}
buyer = sale.buyer
vat_id = getattr(buyer, "vat_id", None) if buyer else None
return {
"verified": False,
"message": "Manual review required",
"vat_id": vat_id,
}
+257
View File
@@ -0,0 +1,257 @@
"""Contract PDF generation using WeasyPrint.
Generates a Verkaufvertrag (sales contract) PDF from an HTML template
with vehicle data, contact data, GwG-Klausel, and USt-IdNr. field.
"""
import asyncio
import os
from datetime import date
from decimal import Decimal
from typing import Any
from app.config import settings
# HTML template for the sales contract
_CONTRACT_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; font-size: 11pt; margin: 2cm; }}
h1 {{ text-align: center; font-size: 16pt; margin-bottom: 20px; }}
h2 {{ font-size: 13pt; border-bottom: 1px solid #333; padding-bottom: 3px; }}
table {{ width: 100%; border-collapse: collapse; margin: 10px 0; }}
td {{ padding: 4px 8px; vertical-align: top; }}
td.label {{ font-weight: bold; width: 35%; }}
.section {{ margin-bottom: 20px; }}
.gwg-clause {{
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 10px;
margin: 15px 0;
border-radius: 4px;
}}
.gwg-clause h3 {{ margin-top: 0; color: #856404; }}
.signature-block {{ margin-top: 40px; }}
.signature-line {{ border-top: 1px solid #333; width: 45%; margin-top: 50px; display: inline-block; text-align: center; font-size: 9pt; }}
.signature-spacer {{ width: 10%; display: inline-block; }}
.footer {{ margin-top: 30px; font-size: 9pt; color: #666; border-top: 1px solid #ccc; padding-top: 10px; }}
</style>
</head>
<body>
<h1>Kaufvertrag über ein Nutzfahrzeug</h1>
<div class="section">
<h2>1. Vertragsparteien</h2>
<table>
<tr><td class="label">Verkäufer:</td><td>{seller_name}<br>{seller_address}<br>{seller_country}</td></tr>
<tr><td class="label">USt-IdNr. (Verkäufer):</td><td>{seller_vat_id}</td></tr>
<tr><td class="label">Käufer:</td><td>{buyer_name}<br>{buyer_address}<br>{buyer_country}</td></tr>
<tr><td class="label">USt-IdNr. (Käufer):</td><td>{buyer_vat_id}</td></tr>
</table>
</div>
<div class="section">
<h2>2. Vertragsgegenstand</h2>
<table>
<tr><td class="label">Fahrzeug:</td><td>{vehicle_make} {vehicle_model}</td></tr>
<tr><td class="label">Fahrgestellnummer (FIN):</td><td>{vehicle_fin}</td></tr>
<tr><td class="label">Fahrzeugtyp:</td><td>{vehicle_type}</td></tr>
<tr><td class="label">Erstzulassung:</td><td>{first_registration}</td></tr>
<tr><td class="label">Kilometerstand:</td><td>{mileage_km} km</td></tr>
<tr><td class="label">Leistung:</td><td>{power_kw} kW ({power_hp} PS)</td></tr>
<tr><td class="label">Kraftstoff:</td><td>{fuel_type}</td></tr>
</table>
</div>
<div class="section">
<h2>3. Kaufpreis</h2>
<table>
<tr><td class="label">Kaufpreis:</td><td>{sale_price} EUR</td></tr>
<tr><td class="label">Verkaufsdatum:</td><td>{sale_date}</td></tr>
</table>
</div>
{gwg_section}
<div class="section">
<h2>4. Zahlungsbedingungen</h2>
<p>Der Kaufpreis ist bei Übergabe des Fahrzeugs fällig. Die Zahlung erfolgt per
Überweisung auf das Konto des Verkäufers.</p>
</div>
<div class="section">
<h2>5. Gewährleistung</h2>
<p>Das Fahrzeug wird unter Ausschluss der Sachmängelhaftung verkauft, soweit
nicht ausdrücklich etwas anderes vereinbart wurde.</p>
</div>
<div class="section">
<h2>6. Übergabe</h2>
<p>Das Fahrzeug wird am {sale_date} übergeben. Mit der Übergabe geht die
Gefahr auf den Käufer über.</p>
</div>
<div class="signature-block">
<div class="signature-line">Verkäufer</div>
<div class="signature-spacer"></div>
<div class="signature-line">Käufer</div>
</div>
<div class="footer">
Vertrag erstellt am {created_date} | Vertragsnummer: {contract_number}
</div>
</body>
</html>"""
_GWG_CLAUSE_HTML = """
<div class="gwg-clause">
<h3>Geringwertige Wirtschaftsgüter (GwG) § 6 Abs. 2 EStG</h3>
<p>Der Kaufpreis beträgt maximal 800 EUR. Nach § 6 Abs. 2 EStG kann der
Verkäufer den vollständigen Betrag im Jahr der Anschaffung als
Sofortabzug im Betriebsvermögen geltend machen. Die
Abschreibung erfolgt somit sofort und vollständig in der
Anschaffungsperiode.</p>
</div>
"""
def _format_contact_name(contact: Any) -> str:
"""Format contact name for the contract."""
if contact is None:
return "N/A"
return getattr(contact, "company_name", "N/A") or "N/A"
def _format_contact_address(contact: Any) -> str:
"""Format contact address for the contract."""
if contact is None:
return "N/A"
parts = []
street = getattr(contact, "address_street", None)
zip_code = getattr(contact, "address_zip", None)
city = getattr(contact, "address_city", None)
if street:
parts.append(street)
if zip_code and city:
parts.append(f"{zip_code} {city}")
elif city:
parts.append(city)
elif zip_code:
parts.append(zip_code)
return "<br>".join(parts) if parts else "N/A"
def _format_contact_country(contact: Any) -> str:
"""Format contact country for the contract."""
if contact is None:
return "N/A"
return getattr(contact, "address_country", "DE") or "DE"
def _format_vat_id(contact: Any) -> str:
"""Format VAT ID for the contract."""
if contact is None:
return "Nicht angegeben"
vat_id = getattr(contact, "vat_id", None)
return vat_id if vat_id else "Nicht angegeben"
def _format_price(price: Decimal) -> str:
"""Format price for the contract."""
if price is None:
return "0,00"
formatted = f"{price:,.2f}"
return formatted.replace(",", "X").replace(".", ",").replace("X", ".")
def _format_date(d: date) -> str:
"""Format date in German convention DD.MM.YYYY."""
if d is None:
return "N/A"
return d.strftime("%d.%m.%Y")
def build_contract_html(sale: Any) -> str:
"""Build the HTML contract from a Sale model instance.
Args:
sale: Sale model instance with nested vehicle, buyer, seller.
Returns:
HTML string for the contract.
"""
vehicle = getattr(sale, "vehicle", None)
buyer = getattr(sale, "buyer", None)
seller = getattr(sale, "seller", None)
# Determine GwG clause
gwg_section = ""
if sale.is_gwg and sale.sale_price is not None and sale.sale_price <= Decimal("800"):
gwg_section = _GWG_CLAUSE_HTML
html = _CONTRACT_HTML_TEMPLATE.format(
seller_name=_format_contact_name(seller) if seller else "N/A",
seller_address=_format_contact_address(seller) if seller else "N/A",
seller_country=_format_contact_country(seller) if seller else "DE",
seller_vat_id=_format_vat_id(seller) if seller else "Nicht angegeben",
buyer_name=_format_contact_name(buyer),
buyer_address=_format_contact_address(buyer),
buyer_country=_format_contact_country(buyer),
buyer_vat_id=_format_vat_id(buyer),
vehicle_make=getattr(vehicle, "make", "N/A") if vehicle else "N/A",
vehicle_model=getattr(vehicle, "model", "N/A") if vehicle else "N/A",
vehicle_fin=getattr(vehicle, "fin", "N/A") if vehicle else "N/A",
vehicle_type=getattr(vehicle, "vehicle_type", "N/A") if vehicle else "N/A",
first_registration=_format_date(getattr(vehicle, "first_registration", None)) if vehicle else "N/A",
mileage_km=getattr(vehicle, "mileage_km", "N/A") if vehicle else "N/A",
power_kw=getattr(vehicle, "power_kw", "N/A") if vehicle else "N/A",
power_hp=getattr(vehicle, "power_hp", "N/A") if vehicle else "N/A",
fuel_type=getattr(vehicle, "fuel_type", "N/A") if vehicle else "N/A",
sale_price=_format_price(sale.sale_price),
sale_date=_format_date(sale.sale_date),
gwg_section=gwg_section,
created_date=_format_date(date.today()),
contract_number=str(sale.id)[:8].upper(),
)
return html
def generate_contract_pdf_sync(sale: Any, output_dir: str = "/tmp/contracts") -> str:
"""Generate contract PDF synchronously using WeasyPrint.
Args:
sale: Sale model instance with nested vehicle, buyer, seller.
output_dir: Directory to save the PDF.
Returns:
Path to the generated PDF file.
"""
from weasyprint import HTML
os.makedirs(output_dir, exist_ok=True)
html_content = build_contract_html(sale)
pdf_path = os.path.join(output_dir, f"contract_{sale.id}.pdf")
HTML(string=html_content).write_pdf(pdf_path)
return pdf_path
async def generate_contract_pdf(sale: Any, output_dir: str = "/tmp/contracts") -> str:
"""Generate contract PDF asynchronously (runs WeasyPrint in a thread).
WeasyPrint is synchronous, so we offload it to a thread to avoid
blocking the event loop.
Args:
sale: Sale model instance with nested vehicle, buyer, seller.
output_dir: Directory to save the PDF.
Returns:
Path to the generated PDF file.
"""
return await asyncio.to_thread(generate_contract_pdf_sync, sale, output_dir)
+109
View File
@@ -0,0 +1,109 @@
"""DATEV CSV format helper (Buchungsstapel).
Generates CSV in DATEV-compatible format with headers:
Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
"""
import csv
import io
from datetime import date
from decimal import Decimal
from typing import Any
# DATEV Buchungsstapel CSV headers
DATEV_HEADERS = [
"Datum",
"Konto",
"Gegenkonto",
"Betrag",
"Belegfeld",
"Buchungstext",
]
def generate_datev_csv(sales: list[Any]) -> str:
"""Generate DATEV CSV content from a list of sale objects.
Each sale produces one booking row:
- Datum: sale_date in DD.MM.YYYY format
- Konto: buyer account (1200 = Forderungen aus Lieferungen/Leistungen)
- Gegenkonto: revenue account (8400 = Erlöse aus Warenverkäufen)
- Betrag: sale_price as decimal with comma separator
- Belegfeld: sale ID (short form)
- Buchungstext: vehicle make + model + FIN
Args:
sales: List of Sale model instances with nested vehicle and buyer.
Returns:
CSV string with DATEV headers and one row per sale.
"""
output = io.StringIO()
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL)
# Write header row
writer.writerow(DATEV_HEADERS)
for sale in sales:
# Format date as DD.MM.YYYY (DATEV convention)
datum = sale.sale_date.strftime("%d.%m.%Y") if sale.sale_date else ""
# Account numbers (standard DATEV SKR03)
konto = "1200" # Forderungen aus LuL
gegenkonto = "8400" # Erlöse aus Warenverkäufen
# Amount with comma as decimal separator (DATEV convention)
betrag = _format_amount(sale.sale_price)
# Belegfeld: short sale ID (first 8 chars)
belegfeld = str(sale.id)[:8].upper()
# Buchungstext: vehicle description
vehicle = getattr(sale, "vehicle", None)
if vehicle:
buchungstext = f"{vehicle.make} {vehicle.model} {vehicle.fin}"
else:
buchungstext = f"Verkauf {str(sale.id)[:8]}"
writer.writerow([datum, konto, gegenkonto, betrag, belegfeld, buchungstext])
return output.getvalue()
def _format_amount(amount: Decimal) -> str:
"""Format a Decimal amount for DATEV CSV (comma as decimal separator)."""
if amount is None:
return "0,00"
# Convert to string with 2 decimal places, replace dot with comma
formatted = f"{amount:.2f}"
return formatted.replace(".", ",")
def validate_datev_csv(csv_content: str) -> bool:
"""Validate that CSV content has correct DATEV headers and structure.
Args:
csv_content: CSV string to validate.
Returns:
True if valid, False otherwise.
"""
if not csv_content or not csv_content.strip():
return False
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
try:
header = next(reader)
except StopIteration:
return False
if header != DATEV_HEADERS:
return False
# Check at least that rows have 6 columns each
for row in reader:
if len(row) != 6:
return False
return True
+1
View File
@@ -13,3 +13,4 @@ httpx>=0.27.0
pytest-cov>=5.0.0
aiosqlite>=0.20.0
Pillow>=10.0.0
weasyprint>=62.0
+276
View File
@@ -0,0 +1,276 @@
"""Tests for DATEV export module: export creation, CSV format, date range validation."""
import csv
import io
import uuid
from datetime import date
from decimal import Decimal
from unittest.mock import patch
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sale import Sale
from app.models.vehicle import Vehicle
from app.models.contact import Contact
from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS
@pytest_asyncio.fixture
async def test_vehicle_for_datev(db_session: AsyncSession) -> Vehicle:
"""Create a test vehicle for DATEV tests."""
vehicle = Vehicle(
make="MAN",
model="TGX",
fin="WMA12345678901234",
year=2021,
vehicle_type="lkw",
price=Decimal("55000.00"),
availability="available",
condition="used",
)
db_session.add(vehicle)
await db_session.commit()
await db_session.refresh(vehicle)
return vehicle
@pytest_asyncio.fixture
async def test_buyer_for_datev(db_session: AsyncSession) -> Contact:
"""Create a test buyer contact for DATEV tests."""
contact = Contact(
company_name="DATEV Test Buyer GmbH",
role="kaeufer",
address_country="DE",
vat_id="DE111222333",
address_street="Testweg 3",
address_zip="67890",
address_city="Hamburg",
)
db_session.add(contact)
await db_session.commit()
await db_session.refresh(contact)
return contact
@pytest_asyncio.fixture
async def test_completed_sales(db_session: AsyncSession, test_vehicle_for_datev, test_buyer_for_datev) -> list[Sale]:
"""Create test completed sales within a date range."""
sales = []
for i, (day, price) in enumerate([(5, Decimal("30000.00")), (10, Decimal("25000.00")), (15, Decimal("40000.00"))]):
sale = Sale(
vehicle_id=test_vehicle_for_datev.id,
buyer_contact_id=test_buyer_for_datev.id,
sale_price=price,
sale_date=date(2025, 1, day),
status="completed",
is_gwg=False,
)
db_session.add(sale)
sales.append(sale)
await db_session.commit()
for s in sales:
await db_session.refresh(s)
return sales
class TestDATEVExportAPI:
"""Test DATEV export API endpoints."""
async def test_create_export(self, admin_client: AsyncClient, test_completed_sales):
"""POST /datev/export with valid date range returns 201."""
response = await admin_client.post("/api/v1/datev/export", json={
"start_date": "2025-01-01",
"end_date": "2025-01-31",
})
assert response.status_code == 201
data = response.json()
assert data["start_date"] == "2025-01-01"
assert data["end_date"] == "2025-01-31"
assert data["file_path"] is not None
assert float(data["total_amount"]) == 95000.00
async def test_create_export_invalid_date_range(self, admin_client: AsyncClient):
"""POST /datev/export with start > end returns 422."""
response = await admin_client.post("/api/v1/datev/export", json={
"start_date": "2025-12-31",
"end_date": "2025-01-01",
})
assert response.status_code == 422
async def test_list_exports(self, admin_client: AsyncClient, test_completed_sales):
"""GET /datev/exports returns list of exports."""
# First create an export
await admin_client.post("/api/v1/datev/export", json={
"start_date": "2025-01-01",
"end_date": "2025-01-31",
})
response = await admin_client.get("/api/v1/datev/exports")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["items"]) >= 1
assert "start_date" in data["items"][0]
assert "end_date" in data["items"][0]
async def test_download_export_csv(self, admin_client: AsyncClient, test_completed_sales):
"""GET /datev/exports/:id/download returns CSV content."""
# Create export
create_response = await admin_client.post("/api/v1/datev/export", json={
"start_date": "2025-01-01",
"end_date": "2025-01-31",
})
assert create_response.status_code == 201
export_id = create_response.json()["id"]
# Download
response = await admin_client.get(f"/api/v1/datev/exports/{export_id}/download")
assert response.status_code == 200
assert response.headers["content-type"] == "text/csv; charset=utf-8"
# Parse CSV and verify headers
csv_content = response.text
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
header = next(reader)
assert header == DATEV_HEADERS
# Verify at least one data row
rows = list(reader)
assert len(rows) >= 1
for row in rows:
assert len(row) == 6
async def test_download_export_not_found(self, admin_client: AsyncClient):
"""GET /datev/exports/:nonexistent/download returns 404."""
response = await admin_client.get(f"/api/v1/datev/exports/{uuid.uuid4()}/download")
assert response.status_code == 404
class TestDATEVCSVFormat:
"""Test DATEV CSV format helper functions."""
def test_datev_csv_headers(self):
"""DATEV CSV has correct headers."""
assert DATEV_HEADERS == ["Datum", "Konto", "Gegenkonto", "Betrag", "Belegfeld", "Buchungstext"]
def test_generate_datev_csv_empty(self):
"""Generate DATEV CSV with no sales returns only headers."""
csv_content = generate_datev_csv([])
assert validate_datev_csv(csv_content)
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
header = next(reader)
assert header == DATEV_HEADERS
# No data rows
rows = list(reader)
assert len(rows) == 0
def test_generate_datev_csv_with_sales(self, test_completed_sales):
"""Generate DATEV CSV with sales produces correct rows."""
# test_completed_sales is a fixture but we need to call it differently for sync test
# Instead, create mock objects
class MockVehicle:
make = "MAN"
model = "TGX"
fin = "WMA12345678901234"
class MockSale:
def __init__(self, sale_id, sale_date, sale_price):
self.id = sale_id
self.sale_date = sale_date
self.sale_price = sale_price
self.vehicle = MockVehicle()
sales = [
MockSale(uuid.uuid4(), date(2025, 1, 5), Decimal("30000.00")),
MockSale(uuid.uuid4(), date(2025, 1, 10), Decimal("25000.00")),
]
csv_content = generate_datev_csv(sales)
assert validate_datev_csv(csv_content)
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
header = next(reader)
assert header == DATEV_HEADERS
rows = list(reader)
assert len(rows) == 2
# Check first row format
row1 = rows[0]
assert row1[0] == "05.01.2025" # Datum in DD.MM.YYYY
assert row1[1] == "1200" # Konto
assert row1[2] == "8400" # Gegenkonto
assert "," in row1[3] # Betrag with comma separator
assert row1[4] # Belegfeld (short UUID)
assert "MAN" in row1[5] # Buchungstext contains vehicle make
def test_validate_datev_csv_invalid_headers(self):
"""Validate DATEV CSV with wrong headers returns False."""
csv_content = "Wrong;Headers;Here\nval1;val2;val3"
assert not validate_datev_csv(csv_content)
def test_validate_datev_csv_empty(self):
"""Validate empty CSV returns False."""
assert not validate_datev_csv("")
assert not validate_datev_csv(" ")
def test_validate_datev_csv_valid(self):
"""Validate correct DATEV CSV returns True."""
csv_content = "Datum;Konto;Gegenkonto;Betrag;Belegfeld;Buchungstext\n05.01.2025;1200;8400;30000,00;ABC12345;MAN TGX"
assert validate_datev_csv(csv_content)
def test_datev_csv_amount_format(self):
"""DATEV CSV amount uses comma as decimal separator."""
class MockVehicle:
make = "VW"
model = "Crafter"
fin = "WVW12345678901234"
class MockSale:
def __init__(self):
self.id = uuid.uuid4()
self.sale_date = date(2025, 3, 15)
self.sale_price = Decimal("12345.67")
self.vehicle = MockVehicle()
csv_content = generate_datev_csv([MockSale()])
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
next(reader) # skip header
row = next(reader)
assert row[3] == "12345,67" # Comma as decimal separator
class TestDATEVExportService:
"""Test DATEV export service directly."""
async def test_create_export_no_sales_in_range(self, db_session: AsyncSession):
"""Creating export with no sales in range produces empty CSV with 0 total."""
from app.services import datev_service
export = await datev_service.create_export(
db_session,
start_date=date(2025, 6, 1),
end_date=date(2025, 6, 30),
)
assert export.total_amount == Decimal("0")
assert export.file_path is not None
async def test_list_exports_pagination(self, db_session: AsyncSession):
"""List exports with pagination."""
from app.services import datev_service
# Create a few exports
for _ in range(3):
await datev_service.create_export(
db_session,
start_date=date(2025, 1, 1),
end_date=date(2025, 1, 31),
)
exports, total = await datev_service.list_exports(db_session, page=1, page_size=2)
assert total >= 3
assert len(exports) <= 2
+325
View File
@@ -0,0 +1,325 @@
"""Tests for sales module: CRUD, contract PDF, GwG logic, vehicle status changes."""
import uuid
from datetime import date
from decimal import Decimal
from unittest.mock import patch, MagicMock
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sale import Sale
from app.models.vehicle import Vehicle
from app.models.contact import Contact
from app.utils.contract_pdf import build_contract_html
from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS
@pytest_asyncio.fixture
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
"""Create a test vehicle."""
vehicle = Vehicle(
make="Mercedes",
model="Actros",
fin="WDB96312345678901",
year=2020,
vehicle_type="lkw",
price=Decimal("45000.00"),
availability="available",
condition="used",
)
db_session.add(vehicle)
await db_session.commit()
await db_session.refresh(vehicle)
return vehicle
@pytest_asyncio.fixture
async def test_buyer(db_session: AsyncSession) -> Contact:
"""Create a test buyer contact."""
contact = Contact(
company_name="Test Buyer GmbH",
role="kaeufer",
address_country="DE",
vat_id="DE123456789",
address_street="Teststr. 1",
address_zip="12345",
address_city="Berlin",
email="buyer@test.com",
)
db_session.add(contact)
await db_session.commit()
await db_session.refresh(contact)
return contact
@pytest_asyncio.fixture
async def test_seller(db_session: AsyncSession) -> Contact:
"""Create a test seller contact."""
contact = Contact(
company_name="Test Seller GmbH",
role="verkaeufer",
address_country="DE",
vat_id="DE987654321",
address_street="Sellerstr. 2",
address_zip="54321",
address_city="München",
email="seller@test.com",
)
db_session.add(contact)
await db_session.commit()
await db_session.refresh(contact)
return contact
@pytest_asyncio.fixture
async def test_sale(db_session: AsyncSession, test_vehicle: Vehicle, test_buyer: Contact) -> Sale:
"""Create a test sale."""
sale = Sale(
vehicle_id=test_vehicle.id,
buyer_contact_id=test_buyer.id,
sale_price=Decimal("45000.00"),
sale_date=date(2025, 1, 15),
status="completed",
is_gwg=False,
)
db_session.add(sale)
await db_session.commit()
await db_session.refresh(sale)
return sale
class TestSaleCRUD:
"""Test sale CRUD operations via API."""
@pytest_asyncio.fixture
async def setup_data(self, db_session, test_vehicle, test_buyer):
"""Ensure vehicle and buyer exist."""
return test_vehicle, test_buyer
async def test_list_sales_empty(self, admin_client: AsyncClient):
"""GET /sales with no sales returns empty list."""
response = await admin_client.get("/api/v1/sales/")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
assert data["page"] == 1
assert data["page_size"] == 20
async def test_create_sale(self, admin_client: AsyncClient, test_vehicle, test_buyer):
"""POST /sales with valid data returns 201."""
response = await admin_client.post("/api/v1/sales/", json={
"vehicle_id": str(test_vehicle.id),
"buyer_contact_id": str(test_buyer.id),
"sale_price": "45000.00",
"sale_date": "2025-01-15",
"status": "draft",
"is_gwg": False,
})
assert response.status_code == 201
data = response.json()
assert data["vehicle_id"] == str(test_vehicle.id)
assert data["buyer_contact_id"] == str(test_buyer.id)
assert data["sale_price"] == "45000.00"
assert data["status"] == "draft"
assert data["is_gwg"] is False
async def test_create_sale_without_vehicle_id(self, admin_client: AsyncClient, test_buyer):
"""POST /sales without vehicle_id returns 422."""
response = await admin_client.post("/api/v1/sales/", json={
"buyer_contact_id": str(test_buyer.id),
"sale_price": "45000.00",
})
assert response.status_code == 422
async def test_create_sale_without_buyer_contact_id(self, admin_client: AsyncClient, test_vehicle):
"""POST /sales without buyer_contact_id returns 422."""
response = await admin_client.post("/api/v1/sales/", json={
"vehicle_id": str(test_vehicle.id),
"sale_price": "45000.00",
})
assert response.status_code == 422
async def test_get_sale_by_id(self, admin_client: AsyncClient, test_sale):
"""GET /sales/:id returns sale detail with nested vehicle and contact."""
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(test_sale.id)
assert data["vehicle"]["make"] == "Mercedes"
assert data["buyer"]["company_name"] == "Test Buyer GmbH"
async def test_get_sale_not_found(self, admin_client: AsyncClient):
"""GET /sales/:nonexistent returns 404."""
response = await admin_client.get(f"/api/v1/sales/{uuid.uuid4()}")
assert response.status_code == 404
async def test_update_sale(self, admin_client: AsyncClient, test_sale):
"""PUT /sales/:id updates sale fields."""
response = await admin_client.put(f"/api/v1/sales/{test_sale.id}", json={
"sale_price": "42000.00",
"status": "completed",
})
assert response.status_code == 200
data = response.json()
assert data["sale_price"] == "42000.00"
assert data["status"] == "completed"
async def test_delete_sale_cancels_and_restores_vehicle(self, admin_client: AsyncClient, test_sale, db_session):
"""DELETE /sales/:id cancels sale and restores vehicle status to 'available'."""
# First set vehicle to sold (as create_sale would)
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
vehicle.availability = "sold"
await db_session.commit()
response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled"
# Verify vehicle status restored
await db_session.refresh(vehicle)
assert vehicle.availability == "available"
async def test_list_sales_with_filter(self, admin_client: AsyncClient, test_sale):
"""GET /sales?status=completed filters by status."""
response = await admin_client.get("/api/v1/sales/?status=completed")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
for item in data["items"]:
assert item["status"] == "completed"
async def test_list_sales_with_date_filter(self, admin_client: AsyncClient, test_sale):
"""GET /sales?date_from=2025-01-01&date_to=2025-12-31 filters by date range."""
response = await admin_client.get("/api/v1/sales/?date_from=2025-01-01&date_to=2025-12-31")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
class TestSaleVehicleStatus:
"""Test vehicle status changes on sale create/delete."""
async def test_create_sale_sets_vehicle_sold(self, admin_client: AsyncClient, test_vehicle, test_buyer, db_session):
"""Creating a sale sets vehicle availability to 'sold'."""
response = await admin_client.post("/api/v1/sales/", json={
"vehicle_id": str(test_vehicle.id),
"buyer_contact_id": str(test_buyer.id),
"sale_price": "45000.00",
"sale_date": "2025-01-15",
"status": "draft",
})
assert response.status_code == 201
# Verify vehicle status
await db_session.refresh(test_vehicle)
assert test_vehicle.availability == "sold"
async def test_cancel_sale_restores_vehicle_available(self, admin_client: AsyncClient, test_sale, db_session):
"""Cancelling a sale restores vehicle availability to 'available'."""
# Set vehicle to sold first
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
vehicle.availability = "sold"
await db_session.commit()
response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}")
assert response.status_code == 200
await db_session.refresh(vehicle)
assert vehicle.availability == "available"
class TestContractPDF:
"""Test contract PDF generation and GwG logic."""
async def test_regenerate_contract(self, admin_client: AsyncClient, test_sale):
"""POST /sales/:id/contract regenerates contract PDF."""
with patch("app.services.sale_service.generate_contract_pdf") as mock_pdf:
mock_pdf.return_value = "/tmp/contracts/test_contract.pdf"
response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/contract")
assert response.status_code == 200
data = response.json()
assert data["sale_id"] == str(test_sale.id)
assert "contract_pdf_path" in data
async def test_download_contract_not_found(self, admin_client: AsyncClient, test_sale):
"""GET /sales/:id/contract returns 404 if no PDF generated."""
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
assert response.status_code == 404
async def test_download_contract_pdf(self, admin_client: AsyncClient, test_sale, db_session):
"""GET /sales/:id/contract returns PDF content."""
# Create a fake PDF file
import os
os.makedirs("/tmp/contracts", exist_ok=True)
pdf_path = f"/tmp/contracts/contract_{test_sale.id}.pdf"
with open(pdf_path, "wb") as f:
f.write(b"%PDF-1.4 fake pdf content")
test_sale.contract_pdf_path = pdf_path
db_session.add(test_sale)
await db_session.commit()
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
# Cleanup
if os.path.exists(pdf_path):
os.remove(pdf_path)
def test_gwg_clause_in_contract_html(self, test_sale, test_vehicle, test_buyer):
"""GwG clause appears in contract HTML when is_gwg=true and price <= 800."""
test_sale.is_gwg = True
test_sale.sale_price = Decimal("500.00")
test_sale.vehicle = test_vehicle
test_sale.buyer = test_buyer
html = build_contract_html(test_sale)
assert "Geringwertige Wirtschaftsgüter" in html
assert "§ 6 Abs. 2 EStG" in html
def test_gwg_clause_not_in_contract_when_price_too_high(self, test_sale, test_vehicle, test_buyer):
"""GwG clause does NOT appear when price > 800 even if is_gwg=true."""
test_sale.is_gwg = True
test_sale.sale_price = Decimal("5000.00")
test_sale.vehicle = test_vehicle
test_sale.buyer = test_buyer
html = build_contract_html(test_sale)
assert "Geringwertige Wirtschaftsgüter" not in html
def test_gwg_clause_not_in_contract_when_not_gwg(self, test_sale, test_vehicle, test_buyer):
"""GwG clause does NOT appear when is_gwg=false."""
test_sale.is_gwg = False
test_sale.sale_price = Decimal("500.00")
test_sale.vehicle = test_vehicle
test_sale.buyer = test_buyer
html = build_contract_html(test_sale)
assert "Geringwertige Wirtschaftsgüter" not in html
def test_contract_html_contains_ust_id_field(self, test_sale, test_vehicle, test_buyer):
"""Contract HTML contains USt-IdNr. field."""
test_sale.vehicle = test_vehicle
test_sale.buyer = test_buyer
html = build_contract_html(test_sale)
assert "USt-IdNr" in html
assert "DE123456789" in html
class TestUstIdVerification:
"""Test USt-IdNr. verification endpoint."""
async def test_verify_ust_id_disabled(self, admin_client: AsyncClient, test_sale):
"""POST /sales/:id/verify-ust-id returns not verified when BZSt API disabled."""
response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/verify-ust-id")
assert response.status_code == 200
data = response.json()
assert data["verified"] is False
assert data["message"] == "BZSt API not available"
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { ContractPreview } from '@/components/sales/ContractPreview';
import { getSale, deleteSale, verifyUstId, type SaleResponse } from '@/lib/sales';
export default function SaleDetailPage() {
const params = useParams();
const saleId = params.id as string;
const [sale, setSale] = useState<SaleResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [ustResult, setUstResult] = useState<string | null>(null);
const fetchSale = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getSale(saleId);
setSale(data);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load sale');
} finally {
setLoading(false);
}
}, [saleId]);
useEffect(() => {
fetchSale();
}, [fetchSale]);
const handleDelete = async () => {
if (!confirm('Verkauf wirklich stornieren?')) return;
try {
await deleteSale(saleId);
fetchSale();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to cancel sale');
}
};
const handleVerifyUstId = async () => {
try {
const result = await verifyUstId(saleId);
setUstResult(`${result.verified ? 'Verifiziert' : 'Nicht verifiziert'}: ${result.message}`);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to verify USt-IdNr.');
}
};
if (loading) return <div data-testid="sale-detail-loading">Wird geladen...</div>;
if (error) return <div className="bg-red-50 text-red-600 p-3 rounded" data-testid="sale-detail-error">{error}</div>;
if (!sale) return <div data-testid="sale-detail-not-found">Verkauf nicht gefunden</div>;
return (
<div className="space-y-6" data-testid="sale-detail">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Verkauf #{sale.id.slice(0, 8)}</h1>
<div className="flex gap-2">
<Button onClick={handleVerifyUstId} data-testid="sale-verify-ust-btn">
USt-IdNr. prüfen
</Button>
<Button onClick={handleDelete} variant="secondary" data-testid="sale-delete-btn">
Stornieren
</Button>
</div>
</div>
{ustResult && (
<div className="bg-blue-50 text-blue-600 p-3 rounded" data-testid="sale-ust-result">
{ustResult}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 p-4 rounded">
<h2 className="font-semibold mb-2">Fahrzeug</h2>
{sale.vehicle ? (
<div data-testid="sale-detail-vehicle">
<p>{sale.vehicle.make} {sale.vehicle.model}</p>
<p className="text-sm text-gray-600">FIN: {sale.vehicle.fin}</p>
<p className="text-sm text-gray-600">Typ: {sale.vehicle.vehicle_type}</p>
</div>
) : (
<p className="text-gray-500">Nicht verfügbar</p>
)}
</div>
<div className="bg-gray-50 p-4 rounded">
<h2 className="font-semibold mb-2">Käufer</h2>
{sale.buyer ? (
<div data-testid="sale-detail-buyer">
<p>{sale.buyer.company_name}</p>
<p className="text-sm text-gray-600">{sale.buyer.address_city}, {sale.buyer.address_country}</p>
{sale.buyer.vat_id && <p className="text-sm text-gray-600">USt-IdNr.: {sale.buyer.vat_id}</p>}
</div>
) : (
<p className="text-gray-500">Nicht verfügbar</p>
)}
</div>
</div>
<div className="bg-gray-50 p-4 rounded">
<h2 className="font-semibold mb-2">Verkaufsdetails</h2>
<div className="grid grid-cols-3 gap-4">
<div>
<span className="text-sm text-gray-500">Preis</span>
<p className="font-medium" data-testid="sale-detail-price">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(sale.sale_price)}
</p>
</div>
<div>
<span className="text-sm text-gray-500">Datum</span>
<p className="font-medium">{new Date(sale.sale_date).toLocaleDateString('de-DE')}</p>
</div>
<div>
<span className="text-sm text-gray-500">Status</span>
<p className="font-medium">{sale.status}</p>
</div>
<div>
<span className="text-sm text-gray-500">GwG</span>
<p className="font-medium">{sale.is_gwg ? 'Ja' : 'Nein'}</p>
</div>
</div>
</div>
<ContractPreview saleId={sale.id} contractPdfPath={sale.contract_pdf_path} />
</div>
);
}
@@ -0,0 +1,5 @@
import { SaleForm } from '@/components/sales/SaleForm';
export default function NeuVerkaufPage() {
return <SaleForm />;
}
+5
View File
@@ -0,0 +1,5 @@
import { SaleList } from '@/components/sales/SaleList';
export default function VerkaufPage() {
return <SaleList />;
}
@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { getContractDownloadUrl, regenerateContract } from '@/lib/sales';
interface ContractPreviewProps {
saleId: string;
contractPdfPath?: string | null;
}
export function ContractPreview({ saleId, contractPdfPath }: ContractPreviewProps) {
const [regenerating, setRegenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pdfPath, setPdfPath] = useState<string | null>(contractPdfPath || null);
const handleRegenerate = async () => {
setRegenerating(true);
setError(null);
try {
const result = await regenerateContract(saleId);
setPdfPath(result.contract_pdf_path);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to regenerate contract');
} finally {
setRegenerating(false);
}
};
const downloadUrl = getContractDownloadUrl(saleId);
return (
<div className="space-y-4" data-testid="contract-preview">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Vertrags-PDF</h2>
<div className="flex gap-2">
<Button
onClick={handleRegenerate}
disabled={regenerating}
data-testid="contract-regenerate-btn"
>
{regenerating ? 'Wird erstellt...' : 'Vertrag neu generieren'}
</Button>
{pdfPath && (
<a href={downloadUrl} target="_blank" rel="noopener noreferrer" data-testid="contract-download-link">
<Button>Herunterladen</Button>
</a>
)}
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded" data-testid="contract-error">
{error}
</div>
)}
{pdfPath ? (
<iframe
src={downloadUrl}
className="w-full h-[600px] border rounded"
data-testid="contract-iframe"
title="Vertrags-PDF"
/>
) : (
<div className="bg-gray-50 p-8 text-center rounded" data-testid="contract-empty">
<p className="text-gray-500">Es wurde noch kein Vertrag generiert.</p>
<p className="text-sm text-gray-400 mt-2">
Klicken Sie auf "Vertrag neu generieren", um ein PDF zu erstellen.
</p>
</div>
)}
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import {
createDatevExport,
listDatevExports,
getDatevDownloadUrl,
type DATEVExportResponse,
} from '@/lib/datev';
import type { PaginatedResponse } from '@/lib/api';
export function DatevExport() {
const [exports, setExports] = useState<DATEVExportResponse[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [creating, setCreating] = useState(false);
const fetchExports = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: PaginatedResponse<DATEVExportResponse> = await listDatevExports();
setExports(data.items);
setTotal(data.total);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load exports');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchExports();
}, [fetchExports]);
const handleCreateExport = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
setError(null);
setSuccess(null);
try {
const result = await createDatevExport({ start_date: startDate, end_date: endDate });
setSuccess(`Export erstellt: ${result.total_amount} EUR`);
fetchExports();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to create export');
} finally {
setCreating(false);
}
};
const columns = [
{
key: 'start_date',
label: 'Von',
render: (row: DATEVExportResponse) =>
new Date(row.start_date).toLocaleDateString('de-DE'),
},
{
key: 'end_date',
label: 'Bis',
render: (row: DATEVExportResponse) =>
new Date(row.end_date).toLocaleDateString('de-DE'),
},
{
key: 'total_amount',
label: 'Gesamtbetrag',
render: (row: DATEVExportResponse) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(row.total_amount),
},
{
key: 'created_at',
label: 'Erstellt am',
render: (row: DATEVExportResponse) =>
row.created_at ? new Date(row.created_at).toLocaleString('de-DE') : 'N/A',
},
{
key: 'actions',
label: 'Aktion',
render: (row: DATEVExportResponse) => (
<a
href={getDatevDownloadUrl(row.id)}
data-testid={`datev-download-${row.id}`}
className="text-primary hover:underline"
>
CSV herunterladen
</a>
),
},
];
return (
<div className="space-y-4" data-testid="datev-export">
<h1 className="text-2xl font-bold">DATEV Export</h1>
{/* Create export form */}
<form onSubmit={handleCreateExport} className="flex gap-4 items-end" data-testid="datev-export-form">
<div className="flex-1">
<label className="block text-sm font-medium mb-1">Von Datum *</label>
<Input
data-testid="datev-start-date"
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
required
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium mb-1">Bis Datum *</label>
<Input
data-testid="datev-end-date"
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={creating} data-testid="datev-create-btn">
{creating ? 'Wird erstellt...' : 'Export erstellen'}
</Button>
</form>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded" data-testid="datev-error">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-600 p-3 rounded" data-testid="datev-success">
{success}
</div>
)}
{loading ? (
<div data-testid="datev-loading">Wird geladen...</div>
) : (
<Table columns={columns} data={exports} rowKey={(row) => row.id} />
)}
{total === 0 && !loading && (
<p className="text-gray-500" data-testid="datev-empty">Noch keine Exporte vorhanden.</p>
)}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { createSale, type SaleCreateData } from '@/lib/sales';
import { listVehicles, type VehicleResponse } from '@/lib/vehicles';
import { useEffect, useCallback } from 'react';
import type { PaginatedResponse } from '@/lib/api';
export function SaleForm() {
const router = useRouter();
const [vehicles, setVehicles] = useState<VehicleResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<SaleCreateData>({
vehicle_id: '',
buyer_contact_id: '',
sale_price: 0,
sale_date: new Date().toISOString().split('T')[0],
status: 'draft',
is_gwg: false,
});
const fetchVehicles = useCallback(async () => {
try {
const data: PaginatedResponse<VehicleResponse> = await listVehicles({ page: 1, page_size: 100 });
setVehicles(data.items.filter(v => v.availability === 'available'));
} catch {
// ignore - vehicles list is optional
}
}, []);
useEffect(() => {
fetchVehicles();
}, [fetchVehicles]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const sale = await createSale(formData);
router.push(`/de/verkauf/${sale.id}`);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to create sale');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl" data-testid="sale-form">
<h1 className="text-2xl font-bold">Neuer Verkauf</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded" data-testid="sale-form-error">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Fahrzeug *</label>
<select
data-testid="sale-vehicle-select"
className="w-full border rounded px-3 py-2"
value={formData.vehicle_id}
onChange={e => setFormData(prev => ({ ...prev, vehicle_id: e.target.value }))}
required
>
<option value="">Fahrzeug auswählen</option>
{vehicles.map(v => (
<option key={v.id} value={v.id}>
{v.make} {v.model} - {v.fin}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Käufer Kontakt ID *</label>
<Input
data-testid="sale-buyer-input"
value={formData.buyer_contact_id}
onChange={e => setFormData(prev => ({ ...prev, buyer_contact_id: e.target.value }))}
placeholder="UUID des Käufer-Kontakts"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Verkaufspreis (EUR) *</label>
<Input
data-testid="sale-price-input"
type="number"
step="0.01"
min="0"
value={formData.sale_price}
onChange={e => setFormData(prev => ({ ...prev, sale_price: parseFloat(e.target.value) || 0 }))}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Verkaufsdatum</label>
<Input
data-testid="sale-date-input"
type="date"
value={formData.sale_date}
onChange={e => setFormData(prev => ({ ...prev, sale_date: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
data-testid="sale-status-select"
className="w-full border rounded px-3 py-2"
value={formData.status}
onChange={e => setFormData(prev => ({ ...prev, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<option value="completed">Abgeschlossen</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
data-testid="sale-gwg-toggle"
type="checkbox"
id="is_gwg"
checked={formData.is_gwg}
onChange={e => setFormData(prev => ({ ...prev, is_gwg: e.target.checked }))}
className="w-4 h-4"
/>
<label htmlFor="is_gwg" className="text-sm font-medium">
Geringwertiges Wirtschaftsgut (GwG) - §6 Abs. 2 EStG
</label>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={loading} data-testid="sale-submit-btn">
{loading ? 'Wird gespeichert...' : 'Verkauf anlegen'}
</Button>
<Button type="button" onClick={() => router.back()} variant="secondary">
Abbrechen
</Button>
</div>
</form>
);
}
+206
View File
@@ -0,0 +1,206 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Table } from '@/components/ui/Table';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
listSales,
type SaleResponse,
type SaleListParams,
} from '@/lib/sales';
import type { PaginatedResponse } from '@/lib/api';
const STATUS_OPTIONS = ['draft', 'completed', 'cancelled'];
export function SaleList() {
const router = useRouter();
const [sales, setSales] = useState<SaleResponse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<SaleListParams>({
page: 1,
page_size: 20,
});
const fetchSales = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: PaginatedResponse<SaleResponse> = await listSales(filters);
setSales(data.items);
setTotal(data.total);
setPage(data.page);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load sales');
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
fetchSales();
}, [fetchSales]);
const handleFilterChange = (key: keyof SaleListParams, value: string) => {
setFilters(prev => ({
...prev,
page: 1,
[key]: value || undefined,
}));
};
const handlePageChange = (newPage: number) => {
setFilters(prev => ({ ...prev, page: newPage }));
};
const columns = [
{
key: 'sale_date',
label: 'Datum',
render: (row: SaleResponse) =>
new Date(row.sale_date).toLocaleDateString('de-DE'),
},
{
key: 'vehicle',
label: 'Fahrzeug',
render: (row: SaleResponse) =>
row.vehicle ? `${row.vehicle.make} ${row.vehicle.model}` : 'N/A',
},
{
key: 'buyer',
label: 'Käufer',
render: (row: SaleResponse) =>
row.buyer?.company_name || 'N/A',
},
{
key: 'sale_price',
label: 'Preis',
render: (row: SaleResponse) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(row.sale_price),
},
{
key: 'status',
label: 'Status',
render: (row: SaleResponse) => (
<span
data-testid={`sale-status-${row.id}`}
className={`px-2 py-1 rounded text-xs font-medium ${
row.status === 'completed' ? 'bg-green-100 text-green-800' :
row.status === 'cancelled' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}
>
{row.status}
</span>
),
},
{
key: 'is_gwg',
label: 'GwG',
render: (row: SaleResponse) =>
row.is_gwg ? (
<span data-testid={`sale-gwg-${row.id}`} className="text-blue-600 font-medium">Ja</span>
) : 'Nein',
},
{
key: 'actions',
label: 'Aktionen',
render: (row: SaleResponse) => (
<button
data-testid={`sale-row-${row.id}`}
onClick={() => router.push(`/de/verkauf/${row.id}`)}
className="text-primary hover:underline"
>
Details
</button>
),
},
];
const totalPages = Math.ceil(total / pageSize);
return (
<div className="space-y-4" data-testid="sale-list">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Verkäufe</h1>
<Button onClick={() => router.push('/de/verkauf/neu')} data-testid="sale-create-btn">
Neuer Verkauf
</Button>
</div>
{/* Filters */}
<div className="flex gap-4 items-end" data-testid="sale-filters">
<div className="flex-1">
<label className="block text-sm font-medium mb-1">Status</label>
<select
data-testid="sale-status-filter"
className="w-full border rounded px-3 py-2"
value={filters.status || ''}
onChange={e => handleFilterChange('status', e.target.value)}
>
<option value="">Alle</option>
{STATUS_OPTIONS.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium mb-1">Von</label>
<Input
type="date"
data-testid="sale-date-from"
value={filters.date_from || ''}
onChange={e => handleFilterChange('date_from', e.target.value)}
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium mb-1">Bis</label>
<Input
type="date"
data-testid="sale-date-to"
value={filters.date_to || ''}
onChange={e => handleFilterChange('date_to', e.target.value)}
/>
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded" data-testid="sale-error">
{error}
</div>
)}
{loading ? (
<div data-testid="sale-loading">Wird geladen...</div>
) : (
<Table columns={columns} data={sales} rowKey={(row) => row.id} />
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between" data-testid="sale-pagination">
<Button
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<span>Seite {page} von {totalPages}</span>
<Button
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { apiFetch, type PaginatedResponse } from './api';
export interface DATEVExportResponse {
id: string;
start_date: string;
end_date: string;
file_path?: string | null;
total_amount: number;
created_at?: string;
}
export interface DATEVExportCreate {
start_date: string;
end_date: string;
}
export async function createDatevExport(data: DATEVExportCreate): Promise<DATEVExportResponse> {
return apiFetch<DATEVExportResponse>('/datev/export', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function listDatevExports(page = 1, pageSize = 20): Promise<PaginatedResponse<DATEVExportResponse>> {
return apiFetch<PaginatedResponse<DATEVExportResponse>>(`/datev/exports?page=${page}&page_size=${pageSize}`);
}
export function getDatevDownloadUrl(id: string): string {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
return `${baseUrl}/datev/exports/${id}/download`;
}
+121
View File
@@ -0,0 +1,121 @@
import { apiFetch, type PaginatedResponse } from './api';
export interface VehicleNested {
id: string;
make: string;
model: string;
fin: string;
vehicle_type: string;
availability: string;
price: number;
}
export interface ContactNested {
id: string;
company_name: string;
address_city?: string;
address_country: string;
vat_id?: string;
email?: string;
phone?: string;
}
export interface SaleResponse {
id: string;
vehicle_id: string;
buyer_contact_id: string;
seller_contact_id?: string | null;
sale_price: number;
sale_date: string;
status: string;
is_gwg: boolean;
contract_pdf_path?: string | null;
created_at?: string;
updated_at?: string;
vehicle?: VehicleNested;
buyer?: ContactNested;
seller?: ContactNested;
}
export interface SaleCreateData {
vehicle_id: string;
buyer_contact_id: string;
seller_contact_id?: string;
sale_price: number;
sale_date?: string;
status?: string;
is_gwg?: boolean;
}
export interface SaleUpdateData {
sale_price?: number;
sale_date?: string;
status?: string;
is_gwg?: boolean;
seller_contact_id?: string;
}
export interface SaleListParams {
page?: number;
page_size?: number;
status?: string;
date_from?: string;
date_to?: string;
}
export interface ContractResponse {
sale_id: string;
contract_pdf_path: string;
message: string;
}
export interface UstIdVerifyResponse {
verified: boolean;
message: string;
vat_id?: string | null;
}
export async function listSales(params: SaleListParams = {}): Promise<PaginatedResponse<SaleResponse>> {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
if (params.page_size) query.set('page_size', String(params.page_size));
if (params.status) query.set('status', params.status);
if (params.date_from) query.set('date_from', params.date_from);
if (params.date_to) query.set('date_to', params.date_to);
return apiFetch<PaginatedResponse<SaleResponse>>(`/sales/?${query.toString()}`);
}
export async function getSale(id: string): Promise<SaleResponse> {
return apiFetch<SaleResponse>(`/sales/${id}`);
}
export async function createSale(data: SaleCreateData): Promise<SaleResponse> {
return apiFetch<SaleResponse>('/sales/', {
method: 'POST',
body: JSON.stringify(data),
});
}
export async function updateSale(id: string, data: SaleUpdateData): Promise<SaleResponse> {
return apiFetch<SaleResponse>(`/sales/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
export async function deleteSale(id: string): Promise<SaleResponse> {
return apiFetch<SaleResponse>(`/sales/${id}`, { method: 'DELETE' });
}
export async function regenerateContract(id: string): Promise<ContractResponse> {
return apiFetch<ContractResponse>(`/sales/${id}/contract`, { method: 'POST' });
}
export async function verifyUstId(id: string): Promise<UstIdVerifyResponse> {
return apiFetch<UstIdVerifyResponse>(`/sales/${id}/verify-ust-id`, { method: 'POST' });
}
export function getContractDownloadUrl(id: string): string {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
return `${baseUrl}/sales/${id}/contract`;
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { DatevExport } from '@/components/sales/DatevExport';
// Mock the datev lib
vi.mock('@/lib/datev', () => ({
createDatevExport: vi.fn(),
listDatevExports: vi.fn(),
getDatevDownloadUrl: vi.fn().mockReturnValue('http://test/datev.csv'),
}));
import { listDatevExports, createDatevExport } from '@/lib/datev';
describe('DatevExport', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders DATEV export page with title', async () => {
vi.mocked(listDatevExports).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<DatevExport />);
expect(screen.getByText('DATEV Export')).toBeInTheDocument();
expect(screen.getByTestId('datev-export')).toBeInTheDocument();
});
it('has date range inputs', async () => {
vi.mocked(listDatevExports).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<DatevExport />);
expect(screen.getByTestId('datev-start-date')).toBeInTheDocument();
expect(screen.getByTestId('datev-end-date')).toBeInTheDocument();
});
it('has create export button', async () => {
vi.mocked(listDatevExports).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<DatevExport />);
expect(screen.getByTestId('datev-create-btn')).toBeInTheDocument();
});
it('shows empty state when no exports', async () => {
vi.mocked(listDatevExports).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<DatevExport />);
await waitFor(() => {
expect(screen.getByTestId('datev-empty')).toBeInTheDocument();
});
});
it('displays exports in table', async () => {
const mockExport = {
id: 'test-export-id',
start_date: '2025-01-01',
end_date: '2025-01-31',
total_amount: 95000,
created_at: '2025-02-01T10:00:00Z',
};
vi.mocked(listDatevExports).mockResolvedValue({
items: [mockExport],
total: 1,
page: 1,
page_size: 20,
});
render(<DatevExport />);
await waitFor(() => {
expect(screen.getByTestId('datev-download-test-export-id')).toBeInTheDocument();
});
});
});
+152
View File
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { SaleList } from '@/components/sales/SaleList';
import { SaleForm } from '@/components/sales/SaleForm';
import { ContractPreview } from '@/components/sales/ContractPreview';
// Mock the sales lib
vi.mock('@/lib/sales', () => ({
listSales: vi.fn(),
createSale: vi.fn(),
getSale: vi.fn(),
deleteSale: vi.fn(),
regenerateContract: vi.fn(),
verifyUstId: vi.fn(),
getContractDownloadUrl: vi.fn().mockReturnValue('http://test/contract'),
}));
vi.mock('@/lib/vehicles', () => ({
listVehicles: vi.fn().mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20 }),
}));
import { listSales, createSale, regenerateContract } from '@/lib/sales';
describe('SaleList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders sale list with title', async () => {
vi.mocked(listSales).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<SaleList />);
expect(screen.getByText('Verkäufe')).toBeInTheDocument();
expect(screen.getByTestId('sale-list')).toBeInTheDocument();
});
it('displays sales in table', async () => {
const mockSale = {
id: 'test-sale-id',
vehicle_id: 'v1',
buyer_contact_id: 'b1',
sale_price: 45000,
sale_date: '2025-01-15',
status: 'completed',
is_gwg: false,
vehicle: { id: 'v1', make: 'Mercedes', model: 'Actros', fin: 'WDB123', vehicle_type: 'lkw', availability: 'sold', price: 45000 },
buyer: { id: 'b1', company_name: 'Test Buyer', address_country: 'DE' },
};
vi.mocked(listSales).mockResolvedValue({
items: [mockSale],
total: 1,
page: 1,
page_size: 20,
});
render(<SaleList />);
await waitFor(() => {
expect(screen.getByText('Mercedes Actros')).toBeInTheDocument();
expect(screen.getByText('Test Buyer')).toBeInTheDocument();
});
});
it('shows create button', async () => {
vi.mocked(listSales).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<SaleList />);
expect(screen.getByTestId('sale-create-btn')).toBeInTheDocument();
});
it('has status filter', async () => {
vi.mocked(listSales).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<SaleList />);
expect(screen.getByTestId('sale-status-filter')).toBeInTheDocument();
});
it('has date range filters', async () => {
vi.mocked(listSales).mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
});
render(<SaleList />);
expect(screen.getByTestId('sale-date-from')).toBeInTheDocument();
expect(screen.getByTestId('sale-date-to')).toBeInTheDocument();
});
});
describe('SaleForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders form with required fields', async () => {
render(<SaleForm />);
expect(screen.getByTestId('sale-form')).toBeInTheDocument();
expect(screen.getByTestId('sale-vehicle-select')).toBeInTheDocument();
expect(screen.getByTestId('sale-buyer-input')).toBeInTheDocument();
expect(screen.getByTestId('sale-price-input')).toBeInTheDocument();
expect(screen.getByTestId('sale-gwg-toggle')).toBeInTheDocument();
});
it('has GwG toggle checkbox', async () => {
render(<SaleForm />);
const gwgToggle = screen.getByTestId('sale-gwg-toggle') as HTMLInputElement;
expect(gwgToggle.type).toBe('checkbox');
expect(gwgToggle.checked).toBe(false);
});
it('has submit button', async () => {
render(<SaleForm />);
expect(screen.getByTestId('sale-submit-btn')).toBeInTheDocument();
});
});
describe('ContractPreview', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows empty state when no PDF path', async () => {
render(<ContractPreview saleId="test-id" contractPdfPath={null} />);
expect(screen.getByTestId('contract-empty')).toBeInTheDocument();
});
it('shows regenerate button', async () => {
render(<ContractPreview saleId="test-id" contractPdfPath={null} />);
expect(screen.getByTestId('contract-regenerate-btn')).toBeInTheDocument();
});
it('shows iframe when PDF path exists', async () => {
render(<ContractPreview saleId="test-id" contractPdfPath="/tmp/contract.pdf" />);
expect(screen.getByTestId('contract-iframe')).toBeInTheDocument();
});
});
+87 -28
View File
@@ -1,44 +1,103 @@
# Test Report - T01: Auth + User Management + RBAC + Base Frontend Layout + i18n
# Test Report - T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
## Backend Tests
**Command**: `cd backend && python -m pytest tests/ --cov=app --cov-report=term-missing -v`
**Command:**
```
cd backend && python -m pytest tests/test_sales.py tests/test_datev.py --cov=app --cov-report=term-missing -v
```
**Result**: 50 passed in 20.40s
**Result:** 34 passed in 11.42s
### Coverage
| Module | Stmts | Miss | Cover |
|--------|-------|------|-------|
| app/routers/auth.py | 24 | 0 | 100% |
| app/routers/users.py | 35 | 1 | 97% |
| app/services/auth_service.py | 86 | 4 | 95% |
| **TOTAL** | 359 | 25 | 93% |
### Test Breakdown
### Test Files
- `tests/test_health.py` (3 tests): health check, no-auth, root endpoint
- `tests/test_auth.py` (12 tests): login valid/invalid/inactive/nonexistent, refresh valid/invalid/access-rejected, me with/without/invalid/refresh token
- `tests/test_users.py` (15 tests): list admin/non-admin/no-auth, pagination, create admin/non-admin/duplicate/short-pw, update admin/nonexistent, delete soft/nonexistent/non-admin, password hash exclusion
- `tests/test_auth_service.py` (20 tests): hash/verify, get_by_email/id found/notfound, authenticate valid/wrong/inactive/nonexistent, token pair, refresh valid/invalid/nonexistent, create success/duplicate, list pagination, update success/notfound/role, deactivate success/notfound
#### test_sales.py (20 tests)
- TestSaleCRUD::test_list_sales_empty ✅
- TestSaleCRUD::test_create_sale ✅
- TestSaleCRUD::test_create_sale_without_vehicle_id ✅ (422)
- TestSaleCRUD::test_create_sale_without_buyer_contact_id ✅ (422)
- TestSaleCRUD::test_get_sale_by_id ✅ (nested vehicle + buyer)
- TestSaleCRUD::test_get_sale_not_found ✅ (404)
- TestSaleCRUD::test_update_sale ✅
- TestSaleCRUD::test_delete_sale_cancels_and_restores_vehicle ✅
- TestSaleCRUD::test_list_sales_with_filter ✅ (status filter)
- TestSaleCRUD::test_list_sales_with_date_filter ✅ (date range)
- TestSaleVehicleStatus::test_create_sale_sets_vehicle_sold ✅
- TestSaleVehicleStatus::test_cancel_sale_restores_vehicle_available ✅
- TestContractPDF::test_regenerate_contract ✅
- TestContractPDF::test_download_contract_not_found ✅ (404)
- TestContractPDF::test_download_contract_pdf ✅ (application/pdf)
- TestContractPDF::test_gwg_clause_in_contract_html ✅ (GwG bei <=800EUR)
- TestContractPDF::test_gwg_clause_not_in_contract_when_price_too_high ✅
- TestContractPDF::test_gwg_clause_not_in_contract_when_not_gwg ✅
- TestContractPDF::test_contract_html_contains_ust_id_field ✅
- TestUstIdVerification::test_verify_ust_id_disabled ✅ (BZSt API not available)
#### test_datev.py (14 tests)
- TestDATEVExportAPI::test_create_export ✅ (201)
- TestDATEVExportAPI::test_create_export_invalid_date_range ✅ (422)
- TestDATEVExportAPI::test_list_exports ✅
- TestDATEVExportAPI::test_download_export_csv ✅ (text/csv)
- TestDATEVExportAPI::test_download_export_not_found ✅ (404)
- TestDATEVCSVFormat::test_datev_csv_headers ✅
- TestDATEVCSVFormat::test_generate_datev_csv_empty ✅
- TestDATEVCSVFormat::test_generate_datev_csv_with_sales ✅
- TestDATEVCSVFormat::test_validate_datev_csv_invalid_headers ✅
- TestDATEVCSVFormat::test_validate_datev_csv_empty ✅
- TestDATEVCSVFormat::test_validate_datev_csv_valid ✅
- TestDATEVCSVFormat::test_datev_csv_amount_format ✅ (comma separator)
- TestDATEVExportService::test_create_export_no_sales_in_range ✅
- TestDATEVExportService::test_list_exports_pagination ✅
### Coverage (T06 modules)
- app/models/sale.py: 94%
- app/models/datev_export.py: 89%
- app/schemas/sale.py: 100%
- app/schemas/datev.py: 100%
- app/routers/sales.py: 65%
- app/routers/datev.py: 76%
- app/services/sale_service.py: 43%
- app/services/datev_service.py: 76%
- app/utils/contract_pdf.py: 75%
- app/utils/datev.py: 88%
## Frontend Tests
**Command**: `cd frontend && npx vitest run`
**Command:**
```
cd frontend && npx vitest run tests/sales.test.tsx tests/datev.test.tsx
```
**Result**: 16 passed in 2.15s
**Result:** 16 passed in 1.85s
### Test Files
- `tests/auth.test.tsx` (10 tests): Button render/loading/click, Input label/error, Card title/children, Toast display, i18n German/English/locale-switch
- `tests/i18n.test.tsx` (6 tests): de.json ≥20 keys, en.json ≥20 keys, matching keys, translation function, parameter interpolation, fallback for unknown keys
### Test Breakdown
## TypeScript Check
#### sales.test.tsx (11 tests)
- SaleList > renders sale list with title ✅
- SaleList > displays sales in table ✅
- SaleList > shows create button ✅
- SaleList > has status filter ✅
- SaleList > has date range filters ✅
- SaleForm > renders form with required fields ✅
- SaleForm > has GwG toggle checkbox ✅
- SaleForm > has submit button ✅
- ContractPreview > shows empty state when no PDF path ✅
- ContractPreview > shows regenerate button ✅
- ContractPreview > shows iframe when PDF path exists ✅
**Command**: `cd frontend && npx tsc --noEmit`
**Result**: Exit 0, no errors
#### datev.test.tsx (5 tests)
- DatevExport > renders DATEV export page with title ✅
- DatevExport > has date range inputs ✅
- DatevExport > has create export button ✅
- DatevExport > shows empty state when no exports ✅
- DatevExport > displays exports in table ✅
## Smoke Test
- Backend: FastAPI app starts, health endpoint returns {"status":"ok"}, login endpoint accepts credentials and returns JWT tokens
- Frontend: Login page renders with email/password inputs and submit button, Toast notifications work on error, i18n switches between DE/EN live
- Database: PostgreSQL test DB (erp_test) created, tables auto-created/dropped per test, all async operations work correctly
- Auth: bcrypt password hashing works, JWT access (15min) + refresh (7d) tokens generated and verified, RBAC enforces admin-only on /users endpoints
- Backend: App starts, all endpoints respond correctly (200/201/404/422)
- Frontend: Components render with mocked API, all test IDs present
- GwG logic: Clause appears in contract HTML when is_gwg=true AND price <= 800 EUR
- Vehicle status: Sale creation → vehicle.availability='sold', sale cancel → vehicle.availability='available'
- DATEV CSV: Correct headers (Datum;Konto;Gegenkonto;Betrag;Belegfeld;Buchungstext), comma decimal separator
- USt-IdNr. verify: Returns {verified: false, message: 'BZSt API not available'} when feature flag disabled
- WeasyPrint: Installed and importable, async PDF generation via asyncio.to_thread