feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
This commit is contained in:
+42
-27
@@ -1,37 +1,52 @@
|
|||||||
# Current Status
|
# Current Status
|
||||||
|
|
||||||
## T05: Dateiablage pro Fahrzeug + File UI
|
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
|
||||||
|
|
||||||
**Status:** COMPLETED
|
**Status:** COMPLETED
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- ✅ File model (app/models/file.py) - UUID PK, vehicle_id FK, all fields
|
- ✅ 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
|
||||||
- ✅ File schemas (app/schemas/file.py) - FileUploadResponse, FileResponse, FileListResponse, FileDeleteResponse
|
- ✅ DATEVExport model (app/models/datev_export.py) - UUID PK, start_date, end_date, file_path, total_amount, created_at
|
||||||
- ✅ File service (app/services/file_service.py) - upload, get, list, delete, validate_mime_type, validate_file_size
|
- ✅ Sale schemas (app/schemas/sale.py) - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse
|
||||||
- ✅ Thumbnail utils (app/utils/thumbnails.py) - 200x200 thumbnails via Pillow for jpg/png/webp
|
- ✅ DATEV schemas (app/schemas/datev.py) - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse
|
||||||
- ✅ Files router (app/routers/files.py) - GET/POST/GET/DELETE endpoints registered in main.py
|
- ✅ 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
|
||||||
- ✅ Backend tests (tests/test_files.py) - 43 tests, all passing, 82% coverage
|
- ✅ 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
|
### Frontend
|
||||||
- ✅ Files lib (lib/files.ts) - API functions, types, utilities
|
- ✅ Sales lib (lib/sales.ts) - listSales, getSale, createSale, updateSale, deleteSale, regenerateContract, verifyUstId
|
||||||
- ✅ FileUpload component - Drag-and-drop multi-file upload
|
- ✅ DATEV lib (lib/datev.ts) - createDatevExport, listDatevExports, getDatevDownloadUrl
|
||||||
- ✅ FileList component - File list with thumbnails, delete confirm dialog
|
- ✅ SaleList component - Sales table with status + date range filters, pagination
|
||||||
- ✅ FileGallery component - Gallery view for vehicle images
|
- ✅ SaleForm component - Sale create form with vehicle select, buyer input, price, GwG toggle
|
||||||
- ✅ FilePreview component - File preview modal
|
- ✅ ContractPreview component - PDF embed preview, regenerate button, download link
|
||||||
- ✅ Frontend tests (tests/files.test.tsx) - 25 tests, all passing
|
- ✅ DatevExport component - Date range picker, export list table, CSV download
|
||||||
- ✅ TypeScript build check passes
|
- ✅ 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
|
### Acceptance Criteria Met
|
||||||
- ✅ GET /api/v1/vehicles/:id/files → 200 + list of files
|
- ✅ GET /api/v1/sales → 200 + paginated list with filter (date range, status)
|
||||||
- ✅ POST /api/v1/vehicles/:id/files mit multipart image → 201 + file object
|
- ✅ GET /api/v1/sales/:id → 200 + sale detail with vehicle + contact nested
|
||||||
- ✅ POST /api/v1/vehicles/:id/files mit >20MB file → 413
|
- ✅ POST /api/v1/sales mit valid data → 201 + sale object
|
||||||
- ✅ POST /api/v1/vehicles/:id/files mit invalid MIME type → 422
|
- ✅ POST /api/v1/sales ohne vehicle_id → 422
|
||||||
- ✅ GET /api/v1/vehicles/:id/files/:fileId → 200 + file content (download)
|
- ✅ POST /api/v1/sales ohne buyer_contact_id → 422
|
||||||
- ✅ DELETE /api/v1/vehicles/:id/files/:fileId → 200 + file deleted
|
- ✅ PUT /api/v1/sales/:id → 200 + updated sale
|
||||||
- ✅ File gespeichert in uploads_data volume, path in DB gespeichert
|
- ✅ DELETE /api/v1/sales/:id → 200 (sale cancelled, vehicle status back to available)
|
||||||
- ✅ Image files → Thumbnail generiert (200x200)
|
- ✅ POST /api/v1/sales/:id/contract → 200 + regenerated contract PDF path
|
||||||
- ✅ Frontend File Upload akzeptiert Drag-and-Drop multi-file
|
- ✅ GET /api/v1/sales/:id/contract → 200 + PDF content (application/pdf)
|
||||||
- ✅ Frontend File List zeigt Thumbnails für images
|
- ✅ Sale mit is_gwg=true und sale_price <= 800 → GwG-Klausel in contract
|
||||||
- ✅ Frontend File Delete zeigt Confirm Dialog
|
- ✅ POST /api/v1/sales/:id/verify-ust-id → 200 + {verified: false, message: 'BZSt API not available'}
|
||||||
- ✅ Frontend Gallery view für Fahrzeug-Bilder
|
- ✅ POST /api/v1/datev/export mit date range → 201 + DATEVExport object
|
||||||
- ✅ pytest coverage >= 80% für file module (82%)
|
- ✅ 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)
|
||||||
|
|||||||
+135
-25
@@ -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).",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": [],
|
"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_effort": "L",
|
||||||
"estimated_lines": 600,
|
"estimated_lines": 600,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -88,8 +97,18 @@
|
|||||||
"category": "vehicle",
|
"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').",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M1-F1", "M1-F2", "M1-F3", "M1-F4", "M1-F5", "M1-F6", "M1-F7"],
|
"T01"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M1-F1",
|
||||||
|
"M1-F2",
|
||||||
|
"M1-F3",
|
||||||
|
"M1-F4",
|
||||||
|
"M1-F5",
|
||||||
|
"M1-F6",
|
||||||
|
"M1-F7"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 700,
|
"estimated_lines": 700,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -152,8 +171,17 @@
|
|||||||
"status": "completed",
|
"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.",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M2-F1", "M2-F2", "M2-F3", "M2-F4", "M2-F5"],
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M2-F1",
|
||||||
|
"M2-F2",
|
||||||
|
"M2-F3",
|
||||||
|
"M2-F4",
|
||||||
|
"M2-F5"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 600,
|
"estimated_lines": 600,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -205,8 +233,17 @@
|
|||||||
"category": "contact",
|
"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.",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M3-F1", "M3-F2", "M3-F3", "M3-F4", "M3-F5", "M3-F6"],
|
"T01"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M3-F1",
|
||||||
|
"M3-F2",
|
||||||
|
"M3-F3",
|
||||||
|
"M3-F4",
|
||||||
|
"M3-F5",
|
||||||
|
"M3-F6"
|
||||||
|
],
|
||||||
"estimated_effort": "M",
|
"estimated_effort": "M",
|
||||||
"estimated_lines": 500,
|
"estimated_lines": 500,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -260,8 +297,17 @@
|
|||||||
"category": "file",
|
"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.",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M4-F1", "M4-F2", "M4-F3", "M4-F4", "M4-F5"],
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M4-F1",
|
||||||
|
"M4-F2",
|
||||||
|
"M4-F3",
|
||||||
|
"M4-F4",
|
||||||
|
"M4-F5"
|
||||||
|
],
|
||||||
"estimated_effort": "M",
|
"estimated_effort": "M",
|
||||||
"estimated_lines": 500,
|
"estimated_lines": 500,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -311,8 +357,21 @@
|
|||||||
"category": "sales",
|
"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).",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T02", "T04"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M5-F1", "M5-F2", "M5-F3", "M5-F4", "M5-F5", "M5-F6", "M5-F7", "M5-F8", "M5-F9"],
|
"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_effort": "XL",
|
||||||
"estimated_lines": 800,
|
"estimated_lines": 800,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -376,7 +435,8 @@
|
|||||||
"frontend/components/sales/DatevExport.tsx",
|
"frontend/components/sales/DatevExport.tsx",
|
||||||
"frontend/tests/sales.test.tsx",
|
"frontend/tests/sales.test.tsx",
|
||||||
"frontend/tests/datev.test.tsx"
|
"frontend/tests/datev.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T07",
|
"id": "T07",
|
||||||
@@ -384,8 +444,19 @@
|
|||||||
"category": "copilot",
|
"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.",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M7-F1", "M7-F2", "M7-F3", "M7-F4", "M7-F5", "M7-F6", "M7-F7"],
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M7-F1",
|
||||||
|
"M7-F2",
|
||||||
|
"M7-F3",
|
||||||
|
"M7-F4",
|
||||||
|
"M7-F5",
|
||||||
|
"M7-F6",
|
||||||
|
"M7-F7"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 700,
|
"estimated_lines": 700,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -441,8 +512,18 @@
|
|||||||
"category": "retouch",
|
"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).",
|
"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",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02", "T05"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M8-F1", "M8-F2", "M8-F3", "M8-F4", "M8-F5"],
|
"T01",
|
||||||
|
"T02",
|
||||||
|
"T05"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M8-F1",
|
||||||
|
"M8-F2",
|
||||||
|
"M8-F3",
|
||||||
|
"M8-F4",
|
||||||
|
"M8-F5"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 600,
|
"estimated_lines": 600,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -490,15 +571,44 @@
|
|||||||
],
|
],
|
||||||
"dependency_graph": {
|
"dependency_graph": {
|
||||||
"T01": [],
|
"T01": [],
|
||||||
"T02": ["T01"],
|
"T02": [
|
||||||
"T03": ["T01", "T02"],
|
"T01"
|
||||||
"T04": ["T01"],
|
],
|
||||||
"T05": ["T01", "T02"],
|
"T03": [
|
||||||
"T06": ["T02", "T04"],
|
"T01",
|
||||||
"T07": ["T01", "T02"],
|
"T02"
|
||||||
"T08": ["T01", "T02", "T05"]
|
],
|
||||||
|
"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": {
|
"summary": {
|
||||||
"total_tasks": 8,
|
"total_tasks": 8,
|
||||||
"tasks_with_test_spec": 8,
|
"tasks_with_test_spec": 8,
|
||||||
@@ -509,4 +619,4 @@
|
|||||||
"total_files_to_create": 120,
|
"total_files_to_create": 120,
|
||||||
"estimated_total_lines": 5000
|
"estimated_total_lines": 5000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+43
-26
@@ -1,38 +1,55 @@
|
|||||||
# Worklog
|
# 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
|
### Files Created
|
||||||
|
|
||||||
**Backend:**
|
#### Backend
|
||||||
- backend/app/models/file.py - File SQLAlchemy model (UUID, vehicle_id FK, filename, path, mime_type, size, thumbnail_path)
|
- backend/app/models/sale.py - Sale model with UUID PK, vehicle/buyer/seller FKs, sale_price, status, is_gwg, contract_pdf_path
|
||||||
- backend/app/schemas/file.py - Pydantic schemas (FileUploadResponse, FileResponse, FileListResponse, FileDeleteResponse)
|
- backend/app/models/datev_export.py - DATEVExport model with start_date, end_date, file_path, total_amount
|
||||||
- backend/app/services/file_service.py - File service (upload, get, list, delete, validate_mime_type, validate_file_size)
|
- backend/app/schemas/sale.py - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse
|
||||||
- backend/app/utils/thumbnails.py - Thumbnail generation (200x200) using Pillow for jpg/png/webp
|
- backend/app/schemas/datev.py - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse
|
||||||
- backend/app/routers/files.py - Files router (GET list, POST upload, GET download, DELETE)
|
- backend/app/services/sale_service.py - Full CRUD + contract PDF + GwG logic + USt-IdNr. verification
|
||||||
- backend/tests/test_files.py - 43 tests covering all endpoints, validation, thumbnails, service unit tests
|
- 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
|
||||||
- frontend/lib/files.ts - API functions, types, utilities (listFiles, uploadFile, deleteFile, formatFileSize, isImageMime)
|
- frontend/lib/sales.ts - Sales API client functions and types
|
||||||
- frontend/components/files/FileUpload.tsx - Drag-and-drop multi-file upload with validation
|
- frontend/lib/datev.ts - DATEV API client functions and types
|
||||||
- frontend/components/files/FileList.tsx - File list with thumbnails, download, delete confirm dialog
|
- frontend/components/sales/SaleList.tsx - Sales table with filters and pagination
|
||||||
- frontend/components/files/FileGallery.tsx - Gallery view filtering images only
|
- frontend/components/sales/SaleForm.tsx - Sale create form with GwG toggle
|
||||||
- frontend/components/files/FilePreview.tsx - File preview modal with metadata and download
|
- frontend/components/sales/ContractPreview.tsx - PDF preview with regenerate/download
|
||||||
- frontend/tests/files.test.tsx - 25 tests covering all components and utilities
|
- 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
|
### Files Modified
|
||||||
- backend/app/models/vehicle.py - Added files relationship to Vehicle model
|
- backend/app/config.py - Added BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR
|
||||||
- backend/app/main.py - Registered files router
|
- backend/app/main.py - Registered sales + datev routers
|
||||||
- backend/requirements.txt - Added Pillow>=10.0.0
|
- backend/app/models/__init__.py - Added sale + datev_export imports for Base.metadata registration
|
||||||
|
- backend/requirements.txt - Added weasyprint>=62.0
|
||||||
|
|
||||||
### Test Results
|
### Test Results
|
||||||
- Backend: 43/43 passed, 82% coverage (file_service 95%, thumbnails 88%, files router 55%)
|
- Backend: 34/34 passed (11.42s)
|
||||||
- Frontend: 25/25 passed
|
- Frontend: 16/16 passed (1.85s)
|
||||||
- TypeScript: tsc --noEmit passes with no errors
|
- Total: 50/50 passed
|
||||||
|
|
||||||
### Smoke Test
|
### Smoke Test
|
||||||
- Backend API endpoints fully functional with real PostgreSQL (upload, list, download, delete)
|
- Backend: All endpoints respond correctly (200/201/404/422)
|
||||||
- Thumbnail generation verified for JPEG, PNG, WebP (200x200)
|
- GwG logic: Clause appears when is_gwg=true AND price <= 800 EUR (§6 Abs. 2 EStG)
|
||||||
- MIME validation enforces jpg/png/webp/pdf/doc/docx only
|
- Vehicle status: Sale creation → 'sold', sale cancel → 'available'
|
||||||
- File size limit enforced at 20MB
|
- DATEV CSV: Correct headers, comma decimal separator, DD.MM.YYYY date format
|
||||||
- Frontend drag-and-drop upload works, delete confirmation dialog works, gallery filters images
|
- 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
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
@@ -37,6 +37,8 @@ class Settings(BaseSettings):
|
|||||||
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
|
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
|
||||||
OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
|
OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
|
||||||
MAX_FILE_SIZE_MB: int = 50
|
MAX_FILE_SIZE_MB: int = 50
|
||||||
|
BZST_API_ENABLED: bool = False
|
||||||
|
WEASYPRINT_OUTPUT_DIR: str = "/tmp/contracts"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
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
|
@asynccontextmanager
|
||||||
@@ -44,6 +44,8 @@ api_v1_router.include_router(vehicles.router)
|
|||||||
api_v1_router.include_router(contacts.router)
|
api_v1_router.include_router(contacts.router)
|
||||||
api_v1_router.include_router(files.router)
|
api_v1_router.include_router(files.router)
|
||||||
api_v1_router.include_router(ocr.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)
|
# Health endpoint (no auth required)
|
||||||
@api_v1_router.get("/health", tags=["health"])
|
@api_v1_router.get("/health", tags=["health"])
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}"'},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -13,3 +13,4 @@ httpx>=0.27.0
|
|||||||
pytest-cov>=5.0.0
|
pytest-cov>=5.0.0
|
||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
weasyprint>=62.0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
|
## 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
|
### Test Breakdown
|
||||||
| 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 Files
|
#### test_sales.py (20 tests)
|
||||||
- `tests/test_health.py` (3 tests): health check, no-auth, root endpoint
|
- TestSaleCRUD::test_list_sales_empty ✅
|
||||||
- `tests/test_auth.py` (12 tests): login valid/invalid/inactive/nonexistent, refresh valid/invalid/access-rejected, me with/without/invalid/refresh token
|
- TestSaleCRUD::test_create_sale ✅
|
||||||
- `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
|
- TestSaleCRUD::test_create_sale_without_vehicle_id ✅ (422)
|
||||||
- `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
|
- 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
|
## 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
|
### Test Breakdown
|
||||||
- `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
|
|
||||||
|
|
||||||
## 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`
|
#### datev.test.tsx (5 tests)
|
||||||
|
- DatevExport > renders DATEV export page with title ✅
|
||||||
**Result**: Exit 0, no errors
|
- DatevExport > has date range inputs ✅
|
||||||
|
- DatevExport > has create export button ✅
|
||||||
|
- DatevExport > shows empty state when no exports ✅
|
||||||
|
- DatevExport > displays exports in table ✅
|
||||||
|
|
||||||
## Smoke Test
|
## Smoke Test
|
||||||
|
|
||||||
- Backend: FastAPI app starts, health endpoint returns {"status":"ok"}, login endpoint accepts credentials and returns JWT tokens
|
- Backend: App starts, all endpoints respond correctly (200/201/404/422)
|
||||||
- Frontend: Login page renders with email/password inputs and submit button, Toast notifications work on error, i18n switches between DE/EN live
|
- Frontend: Components render with mocked API, all test IDs present
|
||||||
- Database: PostgreSQL test DB (erp_test) created, tables auto-created/dropped per test, all async operations work correctly
|
- GwG logic: Clause appears in contract HTML when is_gwg=true AND price <= 800 EUR
|
||||||
- Auth: bcrypt password hashing works, JWT access (15min) + refresh (7d) tokens generated and verified, RBAC enforces admin-only on /users endpoints
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user