diff --git a/.a0/current_status.md b/.a0/current_status.md index 943a06d..233e8f7 100644 --- a/.a0/current_status.md +++ b/.a0/current_status.md @@ -1,37 +1,52 @@ # Current Status -## T05: Dateiablage pro Fahrzeug + File UI +## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI **Status:** COMPLETED ### Backend -- ✅ File model (app/models/file.py) - UUID PK, vehicle_id FK, all fields -- ✅ File schemas (app/schemas/file.py) - FileUploadResponse, FileResponse, FileListResponse, FileDeleteResponse -- ✅ File service (app/services/file_service.py) - upload, get, list, delete, validate_mime_type, validate_file_size -- ✅ Thumbnail utils (app/utils/thumbnails.py) - 200x200 thumbnails via Pillow for jpg/png/webp -- ✅ Files router (app/routers/files.py) - GET/POST/GET/DELETE endpoints registered in main.py -- ✅ Backend tests (tests/test_files.py) - 43 tests, all passing, 82% coverage +- ✅ Sale model (app/models/sale.py) - UUID PK, vehicle_id FK, buyer_contact_id FK, seller_contact_id FK nullable, sale_price DECIMAL, sale_date, status enum, is_gwg BOOLEAN, contract_pdf_path +- ✅ DATEVExport model (app/models/datev_export.py) - UUID PK, start_date, end_date, file_path, total_amount, created_at +- ✅ Sale schemas (app/schemas/sale.py) - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse +- ✅ DATEV schemas (app/schemas/datev.py) - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse +- ✅ Sale service (app/services/sale_service.py) - create_sale, get_sale, list_sales, update_sale, cancel_sale, delete_sale, regenerate_contract_pdf, verify_ust_id +- ✅ DATEV service (app/services/datev_service.py) - create_export, list_exports, get_export_csv +- ✅ Contract PDF utils (app/utils/contract_pdf.py) - HTML template with WeasyPrint, GwG-Klausel, USt-IdNr. field, async via asyncio.to_thread +- ✅ DATEV CSV utils (app/utils/datev.py) - Buchungsstapel format: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext +- ✅ Sales router (app/routers/sales.py) - GET/POST/PUT/DELETE /sales, POST/GET /sales/:id/contract, POST /sales/:id/verify-ust-id +- ✅ DATEV router (app/routers/datev.py) - POST /datev/export, GET /datev/exports, GET /datev/exports/:id/download +- ✅ Config updated - BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR +- ✅ main.py updated - sales + datev routers registered +- ✅ requirements.txt - weasyprint>=62.0 added +- ✅ models/__init__.py - all models imported for Base.metadata registration +- ✅ Backend tests (tests/test_sales.py, tests/test_datev.py) - 34 tests, all passing ### Frontend -- ✅ Files lib (lib/files.ts) - API functions, types, utilities -- ✅ FileUpload component - Drag-and-drop multi-file upload -- ✅ FileList component - File list with thumbnails, delete confirm dialog -- ✅ FileGallery component - Gallery view for vehicle images -- ✅ FilePreview component - File preview modal -- ✅ Frontend tests (tests/files.test.tsx) - 25 tests, all passing -- ✅ TypeScript build check passes +- ✅ Sales lib (lib/sales.ts) - listSales, getSale, createSale, updateSale, deleteSale, regenerateContract, verifyUstId +- ✅ DATEV lib (lib/datev.ts) - createDatevExport, listDatevExports, getDatevDownloadUrl +- ✅ SaleList component - Sales table with status + date range filters, pagination +- ✅ SaleForm component - Sale create form with vehicle select, buyer input, price, GwG toggle +- ✅ ContractPreview component - PDF embed preview, regenerate button, download link +- ✅ DatevExport component - Date range picker, export list table, CSV download +- ✅ Pages: verkauf/page.tsx, verkauf/[id]/page.tsx, verkauf/neu/page.tsx +- ✅ Frontend tests (tests/sales.test.tsx, tests/datev.test.tsx) - 16 tests, all passing ### Acceptance Criteria Met -- ✅ GET /api/v1/vehicles/:id/files → 200 + list of files -- ✅ POST /api/v1/vehicles/:id/files mit multipart image → 201 + file object -- ✅ POST /api/v1/vehicles/:id/files mit >20MB file → 413 -- ✅ POST /api/v1/vehicles/:id/files mit invalid MIME type → 422 -- ✅ GET /api/v1/vehicles/:id/files/:fileId → 200 + file content (download) -- ✅ DELETE /api/v1/vehicles/:id/files/:fileId → 200 + file deleted -- ✅ File gespeichert in uploads_data volume, path in DB gespeichert -- ✅ Image files → Thumbnail generiert (200x200) -- ✅ Frontend File Upload akzeptiert Drag-and-Drop multi-file -- ✅ Frontend File List zeigt Thumbnails für images -- ✅ Frontend File Delete zeigt Confirm Dialog -- ✅ Frontend Gallery view für Fahrzeug-Bilder -- ✅ pytest coverage >= 80% für file module (82%) +- ✅ GET /api/v1/sales → 200 + paginated list with filter (date range, status) +- ✅ GET /api/v1/sales/:id → 200 + sale detail with vehicle + contact nested +- ✅ POST /api/v1/sales mit valid data → 201 + sale object +- ✅ POST /api/v1/sales ohne vehicle_id → 422 +- ✅ POST /api/v1/sales ohne buyer_contact_id → 422 +- ✅ PUT /api/v1/sales/:id → 200 + updated sale +- ✅ DELETE /api/v1/sales/:id → 200 (sale cancelled, vehicle status back to available) +- ✅ POST /api/v1/sales/:id/contract → 200 + regenerated contract PDF path +- ✅ GET /api/v1/sales/:id/contract → 200 + PDF content (application/pdf) +- ✅ Sale mit is_gwg=true und sale_price <= 800 → GwG-Klausel in contract +- ✅ POST /api/v1/sales/:id/verify-ust-id → 200 + {verified: false, message: 'BZSt API not available'} +- ✅ POST /api/v1/datev/export mit date range → 201 + DATEVExport object +- ✅ POST /api/v1/datev/export mit invalid date range → 422 +- ✅ GET /api/v1/datev/exports → 200 + list of exports +- ✅ GET /api/v1/datev/exports/:id/download → 200 + CSV content (text/csv) +- ✅ DATEV CSV enthält korrekte Felder: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext +- ✅ Sale creation → Vehicle status='sold' +- ✅ Sale deletion → Vehicle status='available' (in_stock) diff --git a/.a0/task_graph.json b/.a0/task_graph.json index c9ac962..cd9fd30 100644 --- a/.a0/task_graph.json +++ b/.a0/task_graph.json @@ -12,7 +12,16 @@ "description": "Komplettes Auth-System: JWT login/refresh, User CRUD (admin only), RBAC Middleware (admin/verkaeufer/buchhaltung). Backend: User Model, auth_service, auth router, users router, JWT utils, RBAC dependency. Frontend: Root layout, Login page, Auth context/hooks, i18n setup (next-intl oder react-i18next), de.json + en.json Basistranslationen, API client (fetch wrapper mit Auth-Header), Base UI components (Button, Input, Card, Table, Modal, Toast). Health endpoint /api/health. Config via Pydantic BaseSettings (DB URL, Redis URL, JWT Secret, OpenRouter Key, mobile.de credentials).", "assigned_subagent": "implementation_engineer", "dependencies": [], - "requirement_ids": ["M6-F1", "M6-F2", "AUTH-1", "AUTH-2", "AUTH-3", "AUTH-4", "I18N-1", "I18N-2"], + "requirement_ids": [ + "M6-F1", + "M6-F2", + "AUTH-1", + "AUTH-2", + "AUTH-3", + "AUTH-4", + "I18N-1", + "I18N-2" + ], "estimated_effort": "L", "estimated_lines": 600, "acceptance_criteria": [ @@ -88,8 +97,18 @@ "category": "vehicle", "description": "Komplettes Fahrzeug-Modul: Vehicle Model (LKW, Baumaschine, PKW, Stapler, Transporter), vehicle_service mit CRUD + filter/sort/paginate, vehicles router mit allen Endpoints. mobile.de Seller API Integration (Push only): mobilede_service, mobilede_push background task, Ad-Format Mapping, Retry-Queue via Redis. Frontend: Vehicle List page mit Filter (type, status, brand) + Pagination + Sort, Vehicle Detail page, Vehicle Create/Edit form, mobile.de Push Button + Status indicator. Soft-Delete Implementierung (status='deleted').", "assigned_subagent": "implementation_engineer", - "dependencies": ["T01"], - "requirement_ids": ["M1-F1", "M1-F2", "M1-F3", "M1-F4", "M1-F5", "M1-F6", "M1-F7"], + "dependencies": [ + "T01" + ], + "requirement_ids": [ + "M1-F1", + "M1-F2", + "M1-F3", + "M1-F4", + "M1-F5", + "M1-F6", + "M1-F7" + ], "estimated_effort": "L", "estimated_lines": 700, "acceptance_criteria": [ @@ -152,8 +171,17 @@ "status": "completed", "description": "Komplettes OCR-Modul: OCRResult Model, ocr_service mit OpenRouter Qwen2.5-VL Integration, OCR router (upload, get results, apply to vehicle). Async OCR processing via Redis Queue. Prompt-Engineering für ZB I/II: structured JSON output (brand, model, vin, first_registration, mileage, power_kw, fuel_type). Confidence-Score Berechnung. Manual Review bei confidence < 0.7. Response-Caching für identische Scans. Frontend: OCR Upload page (drag-and-drop), OCR Results view mit side-by-side Original Scan + Extracted Data, Apply-to-Vehicle Button.", "assigned_subagent": "implementation_engineer", - "dependencies": ["T01", "T02"], - "requirement_ids": ["M2-F1", "M2-F2", "M2-F3", "M2-F4", "M2-F5"], + "dependencies": [ + "T01", + "T02" + ], + "requirement_ids": [ + "M2-F1", + "M2-F2", + "M2-F3", + "M2-F4", + "M2-F5" + ], "estimated_effort": "L", "estimated_lines": 600, "acceptance_criteria": [ @@ -205,8 +233,17 @@ "category": "contact", "description": "Komplettes Kontakt-Modul: Contact Model (Kunde/Lieferant/Beide, EU/Inland, USt-IdNr.), contact_service mit CRUD + search + filter (type, eu/inland), contacts router. USt-IdNr.-Feld mit Format-Validierung (DE + EU Formate). Contact-Search (name, company, city, email). Frontend: Contact List mit Search + Filter, Contact Detail page, Contact Create/Edit form. EU/Inland Toggle. USt-IdNr. input mit Format-Validierung frontend-seitig.", "assigned_subagent": "implementation_engineer", - "dependencies": ["T01"], - "requirement_ids": ["M3-F1", "M3-F2", "M3-F3", "M3-F4", "M3-F5", "M3-F6"], + "dependencies": [ + "T01" + ], + "requirement_ids": [ + "M3-F1", + "M3-F2", + "M3-F3", + "M3-F4", + "M3-F5", + "M3-F6" + ], "estimated_effort": "M", "estimated_lines": 500, "acceptance_criteria": [ @@ -260,8 +297,17 @@ "category": "file", "description": "Komplettes Dateiablage-Modul: File Model, file_service mit Upload/Download/Delete, files router. MIME-Type-Validierung (images: jpg/png/webp, documents: pdf/doc/docx, scans: jpg/png). Max 20MB pro Datei. Dateien gespeichert im uploads_data Volume. Thumbnail-Generierung für Images. Frontend: File Upload Component (Drag-and-Drop, multi-file), File List mit Thumbnails, File Download, File Delete mit Confirm. Gallery view für Fahrzeug-Bilder.", "assigned_subagent": "implementation_engineer", - "dependencies": ["T01", "T02"], - "requirement_ids": ["M4-F1", "M4-F2", "M4-F3", "M4-F4", "M4-F5"], + "dependencies": [ + "T01", + "T02" + ], + "requirement_ids": [ + "M4-F1", + "M4-F2", + "M4-F3", + "M4-F4", + "M4-F5" + ], "estimated_effort": "M", "estimated_lines": 500, "acceptance_criteria": [ @@ -311,8 +357,21 @@ "category": "sales", "description": "Komplettes Verkaufsmodul: Sale Model, sale_service mit CRUD, sales router. Contract PDF Generierung (Verkaufvertrag mit Vehicle + Contact Daten, GwG-Klausel, USt-IdNr. Feld). DATEV-Export: datev_service, DATEV CSV Format (Buchungsstapel: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext). GwG-Logik: Sofortabzug bei <= 800EUR (§6 Abs. 2 EStG). USt-IdNr.-Prüfung: BZSt API endpoint (Feature-Flag bzst_api_enabled, aktuell disabled → manual review). DATEVExport Model + router. Frontend: Sales List, Sale Detail mit Contract Preview, Sale Create form (Vehicle select, Contact select, Price, GwG toggle), DATEV Export page (Zeitraum wählen, Download CSV).", "assigned_subagent": "implementation_engineer", - "dependencies": ["T02", "T04"], - "requirement_ids": ["M5-F1", "M5-F2", "M5-F3", "M5-F4", "M5-F5", "M5-F6", "M5-F7", "M5-F8", "M5-F9"], + "dependencies": [ + "T02", + "T04" + ], + "requirement_ids": [ + "M5-F1", + "M5-F2", + "M5-F3", + "M5-F4", + "M5-F5", + "M5-F6", + "M5-F7", + "M5-F8", + "M5-F9" + ], "estimated_effort": "XL", "estimated_lines": 800, "acceptance_criteria": [ @@ -376,7 +435,8 @@ "frontend/components/sales/DatevExport.tsx", "frontend/tests/sales.test.tsx", "frontend/tests/datev.test.tsx" - ] + ], + "status": "completed" }, { "id": "T07", @@ -384,8 +444,19 @@ "category": "copilot", "description": "Komplettes KI-Copilot-Modul: Copilot chat_service mit OpenRouter (Claude/GPT-4), copilot router (chat, voice, history, action). System-Prompt mit ERP-Kontext (verfügbare Aktionen: vehicle search/create, contact search, sale overview). Action-System: Copilot kann strukturierte Actions ausführen (GET /api/vehicles, POST /api/contacts, etc.) via function-calling. Voice Input: STT via Browser Web Speech API → text → chat. Chat History (paginated, pro User). Frontend: Copilot Chat Interface (message list, input, send), Voice Input Button (microphone), Action Preview (zeigt was Copilot tun will vor Ausführung), Chat History sidebar.", "assigned_subagent": "implementation_engineer", - "dependencies": ["T01", "T02"], - "requirement_ids": ["M7-F1", "M7-F2", "M7-F3", "M7-F4", "M7-F5", "M7-F6", "M7-F7"], + "dependencies": [ + "T01", + "T02" + ], + "requirement_ids": [ + "M7-F1", + "M7-F2", + "M7-F3", + "M7-F4", + "M7-F5", + "M7-F6", + "M7-F7" + ], "estimated_effort": "L", "estimated_lines": 700, "acceptance_criteria": [ @@ -441,8 +512,18 @@ "category": "retouch", "description": "Komplettes Bildretusche-Modul: Retouch service mit OpenRouter Flux.1-Pro, retouch router (process, get result, price-compare). Bild-Verarbeitung: Upload → Flux.1-Pro Retusche (Hintergrund entfernen/verbessern, Spiegelungen, Farbkorrektur) → retouched image gespeichert. Preisvergleich: mobile.de Listings Scraper/API → comparable vehicles (brand, model, year, price) → Durchschnittspreis berechnet. Frontend: Retouch Upload page (Bild wählen, Retusche starten), Before/After Vergleich (slider), Preisvergleich view (Tabelle mit comparable listings + Durchschnittspreis).", "assigned_subagent": "implementation_engineer", - "dependencies": ["T01", "T02", "T05"], - "requirement_ids": ["M8-F1", "M8-F2", "M8-F3", "M8-F4", "M8-F5"], + "dependencies": [ + "T01", + "T02", + "T05" + ], + "requirement_ids": [ + "M8-F1", + "M8-F2", + "M8-F3", + "M8-F4", + "M8-F5" + ], "estimated_effort": "L", "estimated_lines": 600, "acceptance_criteria": [ @@ -490,15 +571,44 @@ ], "dependency_graph": { "T01": [], - "T02": ["T01"], - "T03": ["T01", "T02"], - "T04": ["T01"], - "T05": ["T01", "T02"], - "T06": ["T02", "T04"], - "T07": ["T01", "T02"], - "T08": ["T01", "T02", "T05"] + "T02": [ + "T01" + ], + "T03": [ + "T01", + "T02" + ], + "T04": [ + "T01" + ], + "T05": [ + "T01", + "T02" + ], + "T06": [ + "T02", + "T04" + ], + "T07": [ + "T01", + "T02" + ], + "T08": [ + "T01", + "T02", + "T05" + ] }, - "execution_order": ["T01", "T02", "T04", "T03", "T05", "T06", "T07", "T08"], + "execution_order": [ + "T01", + "T02", + "T04", + "T03", + "T05", + "T06", + "T07", + "T08" + ], "summary": { "total_tasks": 8, "tasks_with_test_spec": 8, @@ -509,4 +619,4 @@ "total_files_to_create": 120, "estimated_total_lines": 5000 } -} +} \ No newline at end of file diff --git a/.a0/worklog.md b/.a0/worklog.md index 9c942f4..990db04 100644 --- a/.a0/worklog.md +++ b/.a0/worklog.md @@ -1,38 +1,55 @@ # Worklog -## T05: Dateiablage pro Fahrzeug + File UI - 2026-07-17 +## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI + +**Date:** 2026-07-17 +**Status:** COMPLETED ### Files Created -**Backend:** -- backend/app/models/file.py - File SQLAlchemy model (UUID, vehicle_id FK, filename, path, mime_type, size, thumbnail_path) -- backend/app/schemas/file.py - Pydantic schemas (FileUploadResponse, FileResponse, FileListResponse, FileDeleteResponse) -- backend/app/services/file_service.py - File service (upload, get, list, delete, validate_mime_type, validate_file_size) -- backend/app/utils/thumbnails.py - Thumbnail generation (200x200) using Pillow for jpg/png/webp -- backend/app/routers/files.py - Files router (GET list, POST upload, GET download, DELETE) -- backend/tests/test_files.py - 43 tests covering all endpoints, validation, thumbnails, service unit tests +#### Backend +- backend/app/models/sale.py - Sale model with UUID PK, vehicle/buyer/seller FKs, sale_price, status, is_gwg, contract_pdf_path +- backend/app/models/datev_export.py - DATEVExport model with start_date, end_date, file_path, total_amount +- backend/app/schemas/sale.py - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse +- backend/app/schemas/datev.py - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse +- backend/app/services/sale_service.py - Full CRUD + contract PDF + GwG logic + USt-IdNr. verification +- backend/app/services/datev_service.py - Export creation, listing, CSV download +- backend/app/utils/contract_pdf.py - HTML template with WeasyPrint, GwG-Klausel §6 Abs. 2 EStG, USt-IdNr. field +- backend/app/utils/datev.py - DATEV CSV Buchungsstapel format helper +- backend/app/routers/sales.py - Sales CRUD + contract + USt-IdNr. endpoints +- backend/app/routers/datev.py - DATEV export + list + download endpoints +- backend/tests/test_sales.py - 20 tests covering CRUD, contract PDF, GwG, vehicle status, USt-IdNr. +- backend/tests/test_datev.py - 14 tests covering export API, CSV format, validation -**Frontend:** -- frontend/lib/files.ts - API functions, types, utilities (listFiles, uploadFile, deleteFile, formatFileSize, isImageMime) -- frontend/components/files/FileUpload.tsx - Drag-and-drop multi-file upload with validation -- frontend/components/files/FileList.tsx - File list with thumbnails, download, delete confirm dialog -- frontend/components/files/FileGallery.tsx - Gallery view filtering images only -- frontend/components/files/FilePreview.tsx - File preview modal with metadata and download -- frontend/tests/files.test.tsx - 25 tests covering all components and utilities +#### Frontend +- frontend/lib/sales.ts - Sales API client functions and types +- frontend/lib/datev.ts - DATEV API client functions and types +- frontend/components/sales/SaleList.tsx - Sales table with filters and pagination +- frontend/components/sales/SaleForm.tsx - Sale create form with GwG toggle +- frontend/components/sales/ContractPreview.tsx - PDF preview with regenerate/download +- frontend/components/sales/DatevExport.tsx - DATEV export page with date range picker +- frontend/app/[locale]/verkauf/page.tsx - Sales list page +- frontend/app/[locale]/verkauf/[id]/page.tsx - Sale detail with contract preview +- frontend/app/[locale]/verkauf/neu/page.tsx - Sale create page +- frontend/tests/sales.test.tsx - 11 frontend tests +- frontend/tests/datev.test.tsx - 5 frontend tests ### Files Modified -- backend/app/models/vehicle.py - Added files relationship to Vehicle model -- backend/app/main.py - Registered files router -- backend/requirements.txt - Added Pillow>=10.0.0 +- backend/app/config.py - Added BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR +- backend/app/main.py - Registered sales + datev routers +- backend/app/models/__init__.py - Added sale + datev_export imports for Base.metadata registration +- backend/requirements.txt - Added weasyprint>=62.0 ### Test Results -- Backend: 43/43 passed, 82% coverage (file_service 95%, thumbnails 88%, files router 55%) -- Frontend: 25/25 passed -- TypeScript: tsc --noEmit passes with no errors +- Backend: 34/34 passed (11.42s) +- Frontend: 16/16 passed (1.85s) +- Total: 50/50 passed ### Smoke Test -- Backend API endpoints fully functional with real PostgreSQL (upload, list, download, delete) -- Thumbnail generation verified for JPEG, PNG, WebP (200x200) -- MIME validation enforces jpg/png/webp/pdf/doc/docx only -- File size limit enforced at 20MB -- Frontend drag-and-drop upload works, delete confirmation dialog works, gallery filters images +- Backend: All endpoints respond correctly (200/201/404/422) +- GwG logic: Clause appears when is_gwg=true AND price <= 800 EUR (§6 Abs. 2 EStG) +- Vehicle status: Sale creation → 'sold', sale cancel → 'available' +- DATEV CSV: Correct headers, comma decimal separator, DD.MM.YYYY date format +- USt-IdNr. verify: Returns 'BZSt API not available' when feature flag disabled +- WeasyPrint: Installed, async PDF generation via asyncio.to_thread +- Frontend: All components render with mocked API, test IDs present diff --git a/backend/.coverage b/backend/.coverage index da66a18..b818e02 100644 Binary files a/backend/.coverage and b/backend/.coverage differ diff --git a/backend/=62.0 b/backend/=62.0 new file mode 100644 index 0000000..734b48e --- /dev/null +++ b/backend/=62.0 @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index f1dae4b..dcbb0d0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,8 @@ class Settings(BaseSettings): OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1" OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct" MAX_FILE_SIZE_MB: int = 50 + BZST_API_ENABLED: bool = False + WEASYPRINT_OUTPUT_DIR: str = "/tmp/contracts" @property def cors_origins_list(self) -> list[str]: diff --git a/backend/app/main.py b/backend/app/main.py index 3ed7afb..53c38c2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, contacts, files, ocr, users, vehicles +from app.routers import auth, contacts, datev, files, ocr, sales, users, vehicles @asynccontextmanager @@ -44,6 +44,8 @@ api_v1_router.include_router(vehicles.router) api_v1_router.include_router(contacts.router) api_v1_router.include_router(files.router) api_v1_router.include_router(ocr.router) +api_v1_router.include_router(sales.router) +api_v1_router.include_router(datev.router) # Health endpoint (no auth required) @api_v1_router.get("/health", tags=["health"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29..318837a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/datev_export.py b/backend/app/models/datev_export.py new file mode 100644 index 0000000..9c21bec --- /dev/null +++ b/backend/app/models/datev_export.py @@ -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"" + + 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, + } diff --git a/backend/app/models/sale.py b/backend/app/models/sale.py new file mode 100644 index 0000000..47dad1a --- /dev/null +++ b/backend/app/models/sale.py @@ -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"" + + 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, + } diff --git a/backend/app/routers/datev.py b/backend/app/routers/datev.py new file mode 100644 index 0000000..1d628d7 --- /dev/null +++ b/backend/app/routers/datev.py @@ -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}"'}, + ) diff --git a/backend/app/routers/sales.py b/backend/app/routers/sales.py new file mode 100644 index 0000000..4ded994 --- /dev/null +++ b/backend/app/routers/sales.py @@ -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) diff --git a/backend/app/schemas/datev.py b/backend/app/schemas/datev.py new file mode 100644 index 0000000..6b2c230 --- /dev/null +++ b/backend/app/schemas/datev.py @@ -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 diff --git a/backend/app/schemas/sale.py b/backend/app/schemas/sale.py new file mode 100644 index 0000000..792445b --- /dev/null +++ b/backend/app/schemas/sale.py @@ -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 diff --git a/backend/app/services/datev_service.py b/backend/app/services/datev_service.py new file mode 100644 index 0000000..489d894 --- /dev/null +++ b/backend/app/services/datev_service.py @@ -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") diff --git a/backend/app/services/sale_service.py b/backend/app/services/sale_service.py new file mode 100644 index 0000000..e5eca5a --- /dev/null +++ b/backend/app/services/sale_service.py @@ -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, + } diff --git a/backend/app/utils/contract_pdf.py b/backend/app/utils/contract_pdf.py new file mode 100644 index 0000000..ba6589c --- /dev/null +++ b/backend/app/utils/contract_pdf.py @@ -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 = """ + + + + + + + +

Kaufvertrag über ein Nutzfahrzeug

+ +
+

1. Vertragsparteien

+ + + + + +
Verkäufer:{seller_name}
{seller_address}
{seller_country}
USt-IdNr. (Verkäufer):{seller_vat_id}
Käufer:{buyer_name}
{buyer_address}
{buyer_country}
USt-IdNr. (Käufer):{buyer_vat_id}
+
+ +
+

2. Vertragsgegenstand

+ + + + + + + + +
Fahrzeug:{vehicle_make} {vehicle_model}
Fahrgestellnummer (FIN):{vehicle_fin}
Fahrzeugtyp:{vehicle_type}
Erstzulassung:{first_registration}
Kilometerstand:{mileage_km} km
Leistung:{power_kw} kW ({power_hp} PS)
Kraftstoff:{fuel_type}
+
+ +
+

3. Kaufpreis

+ + + +
Kaufpreis:{sale_price} EUR
Verkaufsdatum:{sale_date}
+
+ +{gwg_section} + +
+

4. Zahlungsbedingungen

+

Der Kaufpreis ist bei Übergabe des Fahrzeugs fällig. Die Zahlung erfolgt per +Überweisung auf das Konto des Verkäufers.

+
+ +
+

5. Gewährleistung

+

Das Fahrzeug wird unter Ausschluss der Sachmängelhaftung verkauft, soweit +nicht ausdrücklich etwas anderes vereinbart wurde.

+
+ +
+

6. Übergabe

+

Das Fahrzeug wird am {sale_date} übergeben. Mit der Übergabe geht die +Gefahr auf den Käufer über.

+
+ +
+
Verkäufer
+
+
Käufer
+
+ + + + +""" + + +_GWG_CLAUSE_HTML = """ +
+

Geringwertige Wirtschaftsgüter (GwG) – § 6 Abs. 2 EStG

+

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.

+
+""" + + +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 "
".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) diff --git a/backend/app/utils/datev.py b/backend/app/utils/datev.py new file mode 100644 index 0000000..9147a32 --- /dev/null +++ b/backend/app/utils/datev.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index e601454..c9e98b4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ httpx>=0.27.0 pytest-cov>=5.0.0 aiosqlite>=0.20.0 Pillow>=10.0.0 +weasyprint>=62.0 diff --git a/backend/tests/test_datev.py b/backend/tests/test_datev.py new file mode 100644 index 0000000..493d218 --- /dev/null +++ b/backend/tests/test_datev.py @@ -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 diff --git a/backend/tests/test_sales.py b/backend/tests/test_sales.py new file mode 100644 index 0000000..6c2c3e8 --- /dev/null +++ b/backend/tests/test_sales.py @@ -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" diff --git a/frontend/app/[locale]/verkauf/[id]/page.tsx b/frontend/app/[locale]/verkauf/[id]/page.tsx new file mode 100644 index 0000000..84346c1 --- /dev/null +++ b/frontend/app/[locale]/verkauf/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [ustResult, setUstResult] = useState(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
Wird geladen...
; + if (error) return
{error}
; + if (!sale) return
Verkauf nicht gefunden
; + + return ( +
+
+

Verkauf #{sale.id.slice(0, 8)}

+
+ + +
+
+ + {ustResult && ( +
+ {ustResult} +
+ )} + +
+
+

Fahrzeug

+ {sale.vehicle ? ( +
+

{sale.vehicle.make} {sale.vehicle.model}

+

FIN: {sale.vehicle.fin}

+

Typ: {sale.vehicle.vehicle_type}

+
+ ) : ( +

Nicht verfügbar

+ )} +
+ +
+

Käufer

+ {sale.buyer ? ( +
+

{sale.buyer.company_name}

+

{sale.buyer.address_city}, {sale.buyer.address_country}

+ {sale.buyer.vat_id &&

USt-IdNr.: {sale.buyer.vat_id}

} +
+ ) : ( +

Nicht verfügbar

+ )} +
+
+ +
+

Verkaufsdetails

+
+
+ Preis +

+ {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(sale.sale_price)} +

+
+
+ Datum +

{new Date(sale.sale_date).toLocaleDateString('de-DE')}

+
+
+ Status +

{sale.status}

+
+
+ GwG +

{sale.is_gwg ? 'Ja' : 'Nein'}

+
+
+
+ + +
+ ); +} diff --git a/frontend/app/[locale]/verkauf/neu/page.tsx b/frontend/app/[locale]/verkauf/neu/page.tsx new file mode 100644 index 0000000..5245cc1 --- /dev/null +++ b/frontend/app/[locale]/verkauf/neu/page.tsx @@ -0,0 +1,5 @@ +import { SaleForm } from '@/components/sales/SaleForm'; + +export default function NeuVerkaufPage() { + return ; +} diff --git a/frontend/app/[locale]/verkauf/page.tsx b/frontend/app/[locale]/verkauf/page.tsx new file mode 100644 index 0000000..e0aa94d --- /dev/null +++ b/frontend/app/[locale]/verkauf/page.tsx @@ -0,0 +1,5 @@ +import { SaleList } from '@/components/sales/SaleList'; + +export default function VerkaufPage() { + return ; +} diff --git a/frontend/components/sales/ContractPreview.tsx b/frontend/components/sales/ContractPreview.tsx new file mode 100644 index 0000000..17cb05a --- /dev/null +++ b/frontend/components/sales/ContractPreview.tsx @@ -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(null); + const [pdfPath, setPdfPath] = useState(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 ( +
+
+

Vertrags-PDF

+
+ + {pdfPath && ( + + + + )} +
+
+ + {error && ( +
+ {error} +
+ )} + + {pdfPath ? ( +