mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(crm): Phase 2.2-2.4 backend + contract files — vollständige CRM-Service Implementierung
- Phase 2.3 Forecast: probability-Feld in PipelineStage, GET /crm/deals/forecast Endpoint - Phase 2.2 Import: ImportModule mit preview/execute/history Endpoints (CSV, XLSX, vCard) - Phase 2.4 Enrichment: EnrichmentModule mit /enrich + /settings/integrations/north-data - Contracts: ContractsModule mit CRUD + File-Upload Endpoints (Multer, max 25MB) - Migrations: 20260312_contract_files, 20260312_phase23_forecast - docker-compose.crm.yml: uploads Volume für Vertragsdokumente Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfe672ec96
commit
63cb05d4d8
35 changed files with 2257 additions and 25 deletions
|
|
@ -31,6 +31,7 @@ services:
|
|||
- ./packages/crm-service:/app
|
||||
- /app/node_modules
|
||||
- ./.keys/jwt-public.pem:/app/keys/jwt-public.pem:ro
|
||||
- ./uploads:/app/uploads
|
||||
networks:
|
||||
- insight-web
|
||||
- insight-db
|
||||
|
|
|
|||
|
|
@ -3437,3 +3437,39 @@ Nach Implementierung der 4 Endpoints:
|
|||
|
||||
**Hinweis:** Das Frontend kann vorab gegen den definierten API-Contract gebaut werden. Sobald die Backend-Endpoints live sind, ist die Integration sofort funktional.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-12 | Backend: Vertragsdokumente (Contract Files) — Fertiggestellt
|
||||
|
||||
**Neue/geaenderte Dateien:**
|
||||
|
||||
| Datei | Aenderung |
|
||||
|-------|-----------|
|
||||
| `prisma/crm.schema.prisma` | ContractFile Model + files-Relation auf Contract |
|
||||
| `prisma/migrations/20260312_contract_files/migration.sql` | contract_files Tabelle + Index |
|
||||
| `src/contracts/contracts.service.ts` | +uploadFile, listFiles, getFile, deleteFile; remove() loescht nun auch Dateien auf Disk |
|
||||
| `src/contracts/contracts.controller.ts` | +4 File-Endpoints (Upload, Liste, Download, Delete) |
|
||||
| `Summarize.md` | ContractFile dokumentiert |
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| POST | /api/v1/crm/companies/:id/contracts/:cid/files | Datei hochladen (multipart, field: `file`) |
|
||||
| GET | /api/v1/crm/companies/:id/contracts/:cid/files | Datei-Liste |
|
||||
| GET | /api/v1/crm/companies/:id/contracts/:cid/files/:fid/download | Download (?inline=true fuer Inline-Anzeige) |
|
||||
| DELETE | /api/v1/crm/companies/:id/contracts/:cid/files/:fid | Datei loeschen (DB + Disk) |
|
||||
|
||||
**Sicherheit:**
|
||||
- Tenant-Isolation: tenantId aus JWT, nicht aus Request
|
||||
- Path-Traversal-Schutz: UUID-Prefix auf Disk, `path.basename()` fuer Dateinamen
|
||||
- MIME-Filter: nur PDF, Word, Excel erlaubt
|
||||
- Max 25 MB pro Datei, max 10 Dateien pro Vertrag
|
||||
- Cascade: Vertrag loeschen → Dateien auf Disk + DB werden entfernt
|
||||
|
||||
**Speicherort:** `/app/uploads/contracts/{tenantId}/{contractId}/{uuid}-{originalName}`
|
||||
|
||||
**Docker-Volume:** `./uploads:/app/uploads` in docker-compose.crm.yml ergaenzen
|
||||
|
||||
**TypeScript-Check:** 0 Fehler
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ packages/crm-service/
|
|||
src/
|
||||
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
||||
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule
|
||||
config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*)
|
||||
config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*, NORTH_DATA_*)
|
||||
prisma/ — CrmPrismaService (eigener Client)
|
||||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||
|
|
@ -30,15 +30,18 @@ packages/crm-service/
|
|||
companies/ — CRUD: Unternehmen (Multi-Value emails/phones, Owner m:n, Status, Lexware ERP-Push, Custom Fields)
|
||||
contacts/ — CRUD: Kontakte (Multi-Value emails/phones, Owner m:n, Status, Events, Custom Fields)
|
||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional)
|
||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + Custom Fields
|
||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update, probability)
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + LostReason + Owner m:n + Events + Custom Fields + Forecast
|
||||
import/ — CSV/Excel Import (Phase 2.2): Preview + Execute, Duplikat-Erkennung, GDPR Temp-File-Loesung
|
||||
enrichment/ — Datenanreicherung (Phase 2.4): Unternehmensregister.de + North Data API, Suggestion-Only
|
||||
contracts/ — CRUD: Vertraege (nested unter /companies/:id/contracts, Status DRAFT/ACTIVE/EXPIRED/CANCELLED) + Datei-Upload (PDF/Word/Excel, max 25MB, max 10 pro Vertrag)
|
||||
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
|
||||
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
||||
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
||||
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
||||
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
||||
company-relationships/ — Company-zu-Company Beziehungen (N:M, bidirektional)
|
||||
health/ — Health-Check (DB, Redis, Lexware)
|
||||
health/ — Health-Check (DB, Redis, Lexware, Enrichment)
|
||||
lexware/ — Lexware Office Integration
|
||||
lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
|
||||
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
|
||||
|
|
@ -60,7 +63,7 @@ packages/crm-service/
|
|||
- **Contact** — Kontakte mit Multi-Value emails/phones, Owner m:n, EntityStatus, linkedinUrl/birthday/source/department, Lexware-Verknuepfung
|
||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1) + FOLLOWUP-Typ
|
||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||
- **PipelineStage** — Stufen innerhalb einer Pipeline (+ probability Decimal(3,2) fuer Forecast)
|
||||
- **Deal** — Vorgaenge mit dealVouchers-Relation, Owner m:n, LostReason/LostReasonText, Events
|
||||
- **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER)
|
||||
- **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX)
|
||||
|
|
@ -71,7 +74,8 @@ packages/crm-service/
|
|||
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
||||
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
||||
- **CompanyRelationship** — N:M Company-zu-Company Beziehungen mit Typ und Notizen
|
||||
- **Contract** — Vertraege (DB-Modell vorhanden, UI-Platzhalter)
|
||||
- **Contract** — Vertraege mit title, status (DRAFT/ACTIVE/EXPIRED/CANCELLED), startDate, endDate, value (Decimal 15,2), currency, notes; Company-Relation (Cascade)
|
||||
- **ContractFile** — Vertragsdokumente (PDF/Word/Excel), originalName, storagePath, mimeType, size; Contract-Relation (Cascade)
|
||||
- **LexwareVoucher** — Gecachte Belege aus Lexware Office
|
||||
- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
||||
- **CustomFieldDef** — Benutzerdefinierte Feld-Definitionen (Phase 2.1): entityType, name (Slug), label, fieldType, options (JSONB), isRequired, position. Unique: [tenantId, entityType, name]
|
||||
|
|
@ -130,8 +134,23 @@ CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade)
|
|||
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
||||
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
|
||||
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
||||
| GET | /api/v1/crm/deals/forecast | Umsatz-Forecast (gewichtete Pipeline, Phase 2.3) |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
||||
| GET | /health | Health-Check (DB, Redis, Lexware) |
|
||||
| **Import (Phase 2.2)** | | |
|
||||
| POST | /api/v1/crm/import/preview | Datei-Vorschau (CSV/XLSX, Multipart) |
|
||||
| POST | /api/v1/crm/import/execute | Import ausfuehren (mit Mapping + Duplikat-Strategie) |
|
||||
| **Contracts (Vertraege)** | | |
|
||||
| GET/POST | /api/v1/crm/companies/:id/contracts | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/companies/:id/contracts/:cid | Detail / Update / Delete |
|
||||
| POST | /api/v1/crm/companies/:id/contracts/:cid/files | Datei hochladen (Multipart, max 25MB) |
|
||||
| GET | /api/v1/crm/companies/:id/contracts/:cid/files | Datei-Liste |
|
||||
| GET | /api/v1/crm/companies/:id/contracts/:cid/files/:fid/download | Datei herunterladen (?inline=true) |
|
||||
| DELETE | /api/v1/crm/companies/:id/contracts/:cid/files/:fid | Datei loeschen |
|
||||
| **Enrichment (Phase 2.4)** | | |
|
||||
| POST | /api/v1/crm/companies/:id/enrich | Unternehmensdaten anreichern (Suggestion-Only) |
|
||||
| GET | /api/v1/crm/settings/integrations/north-data | North Data Einstellungen abrufen |
|
||||
| PUT | /api/v1/crm/settings/integrations/north-data | North Data Einstellungen aktualisieren |
|
||||
| GET | /health | Health-Check (DB, Redis, Lexware, Enrichment) |
|
||||
| **Custom Fields** | | |
|
||||
| POST | /api/v1/crm/custom-fields | Feld-Definition erstellen |
|
||||
| GET | /api/v1/crm/custom-fields?entityType=PERSON | Definitionen auflisten (nach Entity-Typ) |
|
||||
|
|
@ -197,13 +216,12 @@ CustomFieldDef (1) --< (n) CustomFieldValue — fieldDefId (Cascade)
|
|||
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
||||
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
||||
- `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte)
|
||||
- `20260312_phase23_forecast` — Phase 2.3: probability-Spalte auf pipeline_stages
|
||||
- `20260312_contract_files` — ContractFile-Tabelle (Vertragsdokumente)
|
||||
|
||||
### Naechste Schritte
|
||||
|
||||
1. Migration `20260312_phase2_custom_fields` auf Server anwenden
|
||||
1. Migrationen auf Server anwenden (phase2_custom_fields + phase23_forecast)
|
||||
2. Container neu bauen und deployen
|
||||
3. Frontend: Custom Fields Admin-UI + Entity-Integration
|
||||
4. Phase 2.2: Kontakt-Import (CSV, Excel, vCard)
|
||||
5. Phase 2.3: Forecast-Endpoint (Probability-Feld auf PipelineStage)
|
||||
6. Phase 2.4: Firmendaten-Anreicherung (Data Enrichment)
|
||||
7. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)
|
||||
3. Frontend: Forecast-Widget, Import-UI, Enrichment-UI
|
||||
4. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)
|
||||
|
|
|
|||
130
packages/crm-service/package-lock.json
generated
130
packages/crm-service/package-lock.json
generated
|
|
@ -22,13 +22,15 @@
|
|||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csv-parser": "^3.2.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^10.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
|
|
@ -37,6 +39,7 @@
|
|||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
|
@ -2559,6 +2562,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
|
||||
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
|
|
@ -3146,6 +3159,15 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
|
|
@ -3859,6 +3881,19 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -4104,6 +4139,15 @@
|
|||
"node": ">= 0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/collect-v8-coverage": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||
|
|
@ -4306,6 +4350,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||
|
|
@ -4360,6 +4416,18 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-parser": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
|
||||
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"csv-parser": "bin/csv-parser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -5587,6 +5655,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
|
|
@ -9075,6 +9152,18 @@
|
|||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-utils": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||
|
|
@ -10239,6 +10328,24 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
@ -10318,6 +10425,27 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -38,13 +38,15 @@
|
|||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csv-parser": "^3.2.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^10.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
|
|
|||
|
|
@ -410,12 +410,31 @@ model Contract {
|
|||
|
||||
// Relationen
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
files ContractFile[]
|
||||
|
||||
@@index([tenantId, companyId])
|
||||
@@map("contracts")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
model ContractFile {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
contractId String @map("contract_id") @db.Uuid
|
||||
originalName String @map("original_name") @db.VarChar(500)
|
||||
storagePath String @map("storage_path") @db.VarChar(1000)
|
||||
mimeType String @map("mime_type") @db.VarChar(200)
|
||||
size Int
|
||||
uploadedBy String @map("uploaded_by") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contractId])
|
||||
@@map("contract_files")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
enum ContractStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
|
|
@ -462,6 +481,7 @@ model PipelineStage {
|
|||
name String @db.VarChar(200)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
color String @default("#6B7280") @db.VarChar(7)
|
||||
probability Decimal @default(0) @map("probability") @db.Decimal(3, 2)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
-- Contract Files: Dokumente an Vertraegen
|
||||
CREATE TABLE app_crm.contract_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
contract_id UUID NOT NULL REFERENCES app_crm.contracts(id) ON DELETE CASCADE,
|
||||
original_name VARCHAR(500) NOT NULL,
|
||||
storage_path VARCHAR(1000) NOT NULL,
|
||||
mime_type VARCHAR(200) NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
uploaded_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_contract_files_contract_id ON app_crm.contract_files(contract_id);
|
||||
|
||||
COMMENT ON TABLE app_crm.contract_files IS 'Vertragsdokumente (PDF, Word, Excel)';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- ============================================================
|
||||
-- Phase 2.3: Forecast — Wahrscheinlichkeit auf Pipeline-Stufen
|
||||
-- ============================================================
|
||||
|
||||
-- Neue Spalte probability auf pipeline_stages
|
||||
ALTER TABLE "app_crm"."pipeline_stages"
|
||||
ADD COLUMN "probability" DECIMAL(3, 2) NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN "app_crm"."pipeline_stages"."probability"
|
||||
IS 'Abschlusswahrscheinlichkeit 0.00 bis 1.00 (z.B. 0.75 = 75%)';
|
||||
|
|
@ -22,6 +22,9 @@ import { CompanyRelationshipsModule } from './company-relationships/company-rela
|
|||
import { TradeEventsModule } from './trade-events/trade-events.module';
|
||||
import { CrmEventsModule } from './events/crm-events.module';
|
||||
import { CustomFieldsModule } from './custom-fields/custom-fields.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { EnrichmentModule } from './enrichment/enrichment.module';
|
||||
import { ContractsModule } from './contracts/contracts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -47,6 +50,9 @@ import { CustomFieldsModule } from './custom-fields/custom-fields.module';
|
|||
TradeEventsModule,
|
||||
CrmEventsModule,
|
||||
CustomFieldsModule,
|
||||
ImportModule,
|
||||
EnrichmentModule,
|
||||
ContractsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,6 +48,15 @@ export class EnvironmentVariables {
|
|||
@IsString()
|
||||
@IsOptional()
|
||||
LEXWARE_API_URL?: string;
|
||||
|
||||
// North Data Integration (optional)
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
NORTH_DATA_API_KEY?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
NORTH_DATA_API_URL?: string;
|
||||
}
|
||||
|
||||
export function validate(
|
||||
|
|
|
|||
239
packages/crm-service/src/contracts/contracts.controller.ts
Normal file
239
packages/crm-service/src/contracts/contracts.controller.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
ParseUUIDPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiConsumes,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { QueryContractsDto } from './dto/query-contracts.dto';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import {
|
||||
paginatedResponse,
|
||||
singleResponse,
|
||||
} from '../common/dto/pagination.dto';
|
||||
|
||||
@ApiTags('Contracts (Vertraege)')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(TenantGuard)
|
||||
@Controller('companies/:companyId/contracts')
|
||||
export class ContractsController {
|
||||
constructor(private readonly contractsService: ContractsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Vertrag anlegen' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
async create(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: CreateContractDto,
|
||||
) {
|
||||
const contract = await this.contractsService.create(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
user.sub,
|
||||
dto,
|
||||
);
|
||||
return singleResponse(contract);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Vertraege einer Firma auflisten (paginiert)' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
async findAll(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Query() query: QueryContractsDto,
|
||||
) {
|
||||
const result = await this.contractsService.findAll(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
query,
|
||||
);
|
||||
return paginatedResponse(
|
||||
result.data,
|
||||
result.total,
|
||||
result.page,
|
||||
result.pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Vertrag-Details abrufen' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async findOne(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
const contract = await this.contractsService.findOne(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
id,
|
||||
);
|
||||
return singleResponse(contract);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Vertrag aktualisieren' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async update(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDto,
|
||||
) {
|
||||
const contract = await this.contractsService.update(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
id,
|
||||
dto,
|
||||
);
|
||||
return singleResponse(contract);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Vertrag loeschen' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async remove(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
const contract = await this.contractsService.remove(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
id,
|
||||
);
|
||||
return singleResponse(contract);
|
||||
}
|
||||
|
||||
// --- Contract Files ---
|
||||
|
||||
@Post(':contractId/files')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Datei an Vertrag hochladen (PDF, Word, Excel)' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
|
||||
async uploadFile(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('contractId', ParseUUIDPipe) contractId: string,
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 26_214_400 }), // 25 MB
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
const result = await this.contractsService.uploadFile(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
contractId,
|
||||
user.sub,
|
||||
file,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Get(':contractId/files')
|
||||
@ApiOperation({ summary: 'Dateien eines Vertrags auflisten' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
|
||||
async listFiles(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('contractId', ParseUUIDPipe) contractId: string,
|
||||
) {
|
||||
const files = await this.contractsService.listFiles(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
contractId,
|
||||
);
|
||||
return singleResponse(files);
|
||||
}
|
||||
|
||||
@Get(':contractId/files/:fileId/download')
|
||||
@ApiOperation({ summary: 'Datei herunterladen / inline anzeigen' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'fileId', type: 'string', format: 'uuid' })
|
||||
@ApiQuery({ name: 'inline', required: false, type: 'boolean' })
|
||||
async downloadFile(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('contractId', ParseUUIDPipe) contractId: string,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@Query('inline') inline: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { file, absPath } = await this.contractsService.getFile(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
contractId,
|
||||
fileId,
|
||||
);
|
||||
|
||||
const disposition =
|
||||
inline === 'true'
|
||||
? `inline; filename="${file.originalName}"`
|
||||
: `attachment; filename="${file.originalName}"`;
|
||||
|
||||
res.setHeader('Content-Type', file.mimeType);
|
||||
res.setHeader('Content-Disposition', disposition);
|
||||
res.sendFile(absPath);
|
||||
}
|
||||
|
||||
@Delete(':contractId/files/:fileId')
|
||||
@ApiOperation({ summary: 'Datei eines Vertrags loeschen' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'contractId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'fileId', type: 'string', format: 'uuid' })
|
||||
async deleteFile(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param('contractId', ParseUUIDPipe) contractId: string,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
) {
|
||||
const result = await this.contractsService.deleteFile(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
contractId,
|
||||
fileId,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
}
|
||||
10
packages/crm-service/src/contracts/contracts.module.ts
Normal file
10
packages/crm-service/src/contracts/contracts.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ContractsController],
|
||||
providers: [ContractsService],
|
||||
exports: [ContractsService],
|
||||
})
|
||||
export class ContractsModule {}
|
||||
306
packages/crm-service/src/contracts/contracts.service.ts
Normal file
306
packages/crm-service/src/contracts/contracts.service.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { QueryContractsDto } from './dto/query-contracts.dto';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const UPLOAD_BASE = '/app/uploads/contracts';
|
||||
const MAX_FILES_PER_CONTRACT = 10;
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ContractsService {
|
||||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
|
||||
async create(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
dto: CreateContractDto,
|
||||
) {
|
||||
// Unternehmen validieren
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.contract.create({
|
||||
data: {
|
||||
tenantId,
|
||||
companyId,
|
||||
title: dto.title,
|
||||
status: dto.status ?? 'DRAFT',
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
value: dto.value,
|
||||
currency: dto.currency ?? 'EUR',
|
||||
notes: dto.notes,
|
||||
createdBy: userId,
|
||||
},
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, companyId: string, query: QueryContractsDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 25;
|
||||
|
||||
const where: Prisma.ContractWhereInput = { tenantId, companyId };
|
||||
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.title = { contains: query.search, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const allowedSortFields = [
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'title',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'value',
|
||||
'status',
|
||||
];
|
||||
const sortField = allowedSortFields.includes(query.sort ?? '')
|
||||
? (query.sort as string)
|
||||
: 'createdAt';
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.contract.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { [sortField]: query.order ?? 'desc' },
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.contract.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, page, pageSize };
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, companyId: string, id: string) {
|
||||
const contract = await this.prisma.contract.findFirst({
|
||||
where: { id, tenantId, companyId },
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
throw new NotFoundException('Vertrag nicht gefunden');
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
id: string,
|
||||
dto: UpdateContractDto,
|
||||
) {
|
||||
await this.findOne(tenantId, companyId, id);
|
||||
|
||||
const data: Prisma.ContractUpdateInput = {};
|
||||
|
||||
if (dto.title !== undefined) data.title = dto.title;
|
||||
if (dto.status !== undefined) data.status = dto.status;
|
||||
if (dto.startDate !== undefined)
|
||||
data.startDate = dto.startDate ? new Date(dto.startDate) : null;
|
||||
if (dto.endDate !== undefined)
|
||||
data.endDate = dto.endDate ? new Date(dto.endDate) : null;
|
||||
if (dto.value !== undefined) data.value = dto.value;
|
||||
if (dto.currency !== undefined) data.currency = dto.currency;
|
||||
if (dto.notes !== undefined) data.notes = dto.notes;
|
||||
|
||||
return this.prisma.contract.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, companyId: string, id: string) {
|
||||
const contract = await this.findOne(tenantId, companyId, id);
|
||||
|
||||
// Dateien auf Disk loeschen bevor DB-Cascade die Records entfernt
|
||||
const files = await this.prisma.contractFile.findMany({
|
||||
where: { contractId: id, tenantId },
|
||||
});
|
||||
for (const file of files) {
|
||||
const absPath = path.resolve(file.storagePath);
|
||||
try {
|
||||
fs.unlinkSync(absPath);
|
||||
} catch {
|
||||
// Datei bereits geloescht oder nicht vorhanden — ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.contract.delete({ where: { id } });
|
||||
return contract;
|
||||
}
|
||||
|
||||
// --- Contract Files ---
|
||||
|
||||
async uploadFile(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
contractId: string,
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
// Vertrag validieren
|
||||
await this.findOne(tenantId, companyId, contractId);
|
||||
|
||||
// MIME-Typ pruefen
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Dateityp '${file.mimetype}' nicht erlaubt. Erlaubt: PDF, Word, Excel`,
|
||||
);
|
||||
}
|
||||
|
||||
// Max-Dateien pruefen
|
||||
const fileCount = await this.prisma.contractFile.count({
|
||||
where: { contractId, tenantId },
|
||||
});
|
||||
if (fileCount >= MAX_FILES_PER_CONTRACT) {
|
||||
throw new BadRequestException(
|
||||
`Maximal ${MAX_FILES_PER_CONTRACT} Dateien pro Vertrag erlaubt`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verzeichnis erstellen
|
||||
const dir = path.join(UPLOAD_BASE, tenantId, contractId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Datei speichern (UUID-Prefix gegen Kollisionen + Path-Traversal)
|
||||
const fileUuid = randomUUID();
|
||||
const safeName = path.basename(file.originalname);
|
||||
const diskName = `${fileUuid}-${safeName}`;
|
||||
const storagePath = path.join(dir, diskName);
|
||||
|
||||
fs.writeFileSync(storagePath, file.buffer);
|
||||
|
||||
// DB-Record anlegen
|
||||
return this.prisma.contractFile.create({
|
||||
data: {
|
||||
tenantId,
|
||||
contractId,
|
||||
originalName: file.originalname,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
uploadedBy: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
contractId: true,
|
||||
originalName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
uploadedBy: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listFiles(tenantId: string, companyId: string, contractId: string) {
|
||||
// Vertrag validieren
|
||||
await this.findOne(tenantId, companyId, contractId);
|
||||
|
||||
return this.prisma.contractFile.findMany({
|
||||
where: { contractId, tenantId },
|
||||
select: {
|
||||
id: true,
|
||||
contractId: true,
|
||||
originalName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
uploadedBy: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getFile(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
contractId: string,
|
||||
fileId: string,
|
||||
) {
|
||||
// Vertrag validieren
|
||||
await this.findOne(tenantId, companyId, contractId);
|
||||
|
||||
const file = await this.prisma.contractFile.findFirst({
|
||||
where: { id: fileId, contractId, tenantId },
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
throw new NotFoundException('Datei nicht gefunden');
|
||||
}
|
||||
|
||||
const absPath = path.resolve(file.storagePath);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
throw new NotFoundException('Datei auf Disk nicht gefunden');
|
||||
}
|
||||
|
||||
return { file, absPath };
|
||||
}
|
||||
|
||||
async deleteFile(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
contractId: string,
|
||||
fileId: string,
|
||||
) {
|
||||
// Vertrag validieren
|
||||
await this.findOne(tenantId, companyId, contractId);
|
||||
|
||||
const file = await this.prisma.contractFile.findFirst({
|
||||
where: { id: fileId, contractId, tenantId },
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
throw new NotFoundException('Datei nicht gefunden');
|
||||
}
|
||||
|
||||
// DB-Record loeschen
|
||||
await this.prisma.contractFile.delete({ where: { id: fileId } });
|
||||
|
||||
// Datei auf Disk loeschen (kein Fehler wenn nicht vorhanden)
|
||||
try {
|
||||
fs.unlinkSync(path.resolve(file.storagePath));
|
||||
} catch {
|
||||
// Datei bereits geloescht — ignorieren
|
||||
}
|
||||
|
||||
return { id: fileId, deleted: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum ContractStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
ACTIVE = 'ACTIVE',
|
||||
EXPIRED = 'EXPIRED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
export class CreateContractDto {
|
||||
@ApiProperty({ maxLength: 255, description: 'Vertragsbezeichnung' })
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ContractStatus,
|
||||
default: ContractStatus.DRAFT,
|
||||
description: 'Vertragsstatus',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ContractStatus)
|
||||
status?: ContractStatus;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time', description: 'Vertragsbeginn' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time', description: 'Vertragsende' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Vertragswert', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
value?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: 'EUR',
|
||||
maxLength: 3,
|
||||
description: 'Waehrung (ISO 4217)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Notizen zum Vertrag' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import { ContractStatus } from './create-contract.dto';
|
||||
|
||||
export class QueryContractsDto extends PaginationDto {
|
||||
@ApiPropertyOptional({ enum: ContractStatus, description: 'Filter nach Status' })
|
||||
@IsOptional()
|
||||
@IsEnum(ContractStatus)
|
||||
status?: ContractStatus;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Suche in Titel' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sort?: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({ enum: ['asc', 'desc'], default: 'desc' })
|
||||
@IsOptional()
|
||||
@IsEnum(['asc', 'desc'] as const)
|
||||
order?: 'asc' | 'desc' = 'desc';
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateContractDto } from './create-contract.dto';
|
||||
|
||||
export class UpdateContractDto extends PartialType(CreateContractDto) {}
|
||||
|
|
@ -22,6 +22,7 @@ import { DealsService } from './deals.service';
|
|||
import { CreateDealDto } from './dto/create-deal.dto';
|
||||
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||
import { ForecastQueryDto } from './dto/forecast-query.dto';
|
||||
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||
import { OwnersService } from '../owners/owners.service';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
|
|
@ -71,6 +72,16 @@ export class DealsController {
|
|||
);
|
||||
}
|
||||
|
||||
@Get('forecast')
|
||||
@ApiOperation({ summary: 'Umsatz-Forecast (gewichtete Pipeline)' })
|
||||
async forecast(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query() query: ForecastQueryDto,
|
||||
) {
|
||||
const result = await this.dealsService.forecast(user.tenantId!, query);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Vorgangsdetails abrufen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
|||
import { CreateDealDto } from './dto/create-deal.dto';
|
||||
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||
import { ForecastQueryDto, ForecastPeriod } from './dto/forecast-query.dto';
|
||||
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
||||
|
|
@ -333,4 +334,123 @@ export class DealsService {
|
|||
|
||||
return this.prisma.deal.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Forecast (gewichtete Pipeline-Aggregation)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async forecast(tenantId: string, query: ForecastQueryDto) {
|
||||
const period = query.period ?? ForecastPeriod.QUARTER;
|
||||
const { start, end } = this.getPeriodBounds(period);
|
||||
|
||||
const where: Prisma.DealWhereInput = {
|
||||
tenantId,
|
||||
status: 'OPEN',
|
||||
expectedCloseDate: { gte: start, lte: end },
|
||||
};
|
||||
|
||||
if (query.pipelineId) {
|
||||
where.pipelineId = query.pipelineId;
|
||||
}
|
||||
|
||||
const deals = await this.prisma.deal.findMany({
|
||||
where,
|
||||
include: {
|
||||
stage: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
probability: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
pipeline: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Nach Stage gruppieren
|
||||
const stageMap = new Map<string, {
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
stageColor: string;
|
||||
probability: number;
|
||||
sortOrder: number;
|
||||
pipelineId: string;
|
||||
pipelineName: string;
|
||||
dealCount: number;
|
||||
totalValue: number;
|
||||
weightedValue: number;
|
||||
}>();
|
||||
|
||||
for (const deal of deals) {
|
||||
const key = deal.stageId;
|
||||
const value = deal.value?.toNumber() ?? 0;
|
||||
const prob = deal.stage.probability?.toNumber() ?? 0;
|
||||
|
||||
if (!stageMap.has(key)) {
|
||||
stageMap.set(key, {
|
||||
stageId: deal.stage.id,
|
||||
stageName: deal.stage.name,
|
||||
stageColor: deal.stage.color,
|
||||
probability: prob,
|
||||
sortOrder: deal.stage.sortOrder,
|
||||
pipelineId: deal.pipeline.id,
|
||||
pipelineName: deal.pipeline.name,
|
||||
dealCount: 0,
|
||||
totalValue: 0,
|
||||
weightedValue: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = stageMap.get(key)!;
|
||||
entry.dealCount += 1;
|
||||
entry.totalValue += value;
|
||||
entry.weightedValue += value * prob;
|
||||
}
|
||||
|
||||
const stages = Array.from(stageMap.values()).sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
|
||||
const totalDeals = stages.reduce((sum, s) => sum + s.dealCount, 0);
|
||||
const totalValue = stages.reduce((sum, s) => sum + s.totalValue, 0);
|
||||
const totalWeightedValue = stages.reduce((sum, s) => sum + s.weightedValue, 0);
|
||||
|
||||
return {
|
||||
period,
|
||||
periodStart: start.toISOString(),
|
||||
periodEnd: end.toISOString(),
|
||||
currency: 'EUR',
|
||||
stages,
|
||||
totals: {
|
||||
dealCount: totalDeals,
|
||||
totalValue,
|
||||
weightedValue: totalWeightedValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getPeriodBounds(period: ForecastPeriod): { start: Date; end: Date } {
|
||||
const now = new Date();
|
||||
|
||||
switch (period) {
|
||||
case ForecastPeriod.MONTH: {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
return { start, end };
|
||||
}
|
||||
case ForecastPeriod.QUARTER: {
|
||||
const quarterStart = Math.floor(now.getMonth() / 3) * 3;
|
||||
const start = new Date(now.getFullYear(), quarterStart, 1);
|
||||
const end = new Date(now.getFullYear(), quarterStart + 3, 0, 23, 59, 59, 999);
|
||||
return { start, end };
|
||||
}
|
||||
case ForecastPeriod.YEAR: {
|
||||
const start = new Date(now.getFullYear(), 0, 1);
|
||||
const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||
return { start, end };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
packages/crm-service/src/deals/dto/forecast-query.dto.ts
Normal file
24
packages/crm-service/src/deals/dto/forecast-query.dto.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { IsOptional, IsUUID, IsEnum } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum ForecastPeriod {
|
||||
MONTH = 'month',
|
||||
QUARTER = 'quarter',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export class ForecastQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Pipeline' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pipelineId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ForecastPeriod,
|
||||
default: ForecastPeriod.QUARTER,
|
||||
description: 'Zeitraum fuer den Forecast',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ForecastPeriod)
|
||||
period?: ForecastPeriod = ForecastPeriod.QUARTER;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export interface EnrichmentSuggestion {
|
||||
current: string | null;
|
||||
suggested: string | null;
|
||||
source: string;
|
||||
confidence?: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
}
|
||||
|
||||
export interface EnrichmentResponse {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
sources: string[];
|
||||
suggestions: Record<string, EnrichmentSuggestion>;
|
||||
enrichedAt: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateNorthDataSettingsDto {
|
||||
@ApiPropertyOptional({ description: 'North Data API Key' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Integration aktiviert?' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NorthDataSettings {
|
||||
apiKey: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
77
packages/crm-service/src/enrichment/enrichment.controller.ts
Normal file
77
packages/crm-service/src/enrichment/enrichment.controller.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import { singleResponse } from '../common/dto/pagination.dto';
|
||||
import { EnrichmentService } from './enrichment.service';
|
||||
import { UpdateNorthDataSettingsDto } from './dto/enrichment-settings.dto';
|
||||
|
||||
@ApiTags('Datenanreicherung (Enrichment)')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(TenantGuard)
|
||||
@Controller()
|
||||
export class EnrichmentController {
|
||||
constructor(private readonly enrichmentService: EnrichmentService) {}
|
||||
|
||||
@Post('companies/:id/enrich')
|
||||
@ApiOperation({
|
||||
summary: 'Unternehmensdaten anreichern (Vorschlaege, kein Auto-Write)',
|
||||
})
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
async enrich(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
const result = await this.enrichmentService.enrichCompany(
|
||||
user.tenantId!,
|
||||
id,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Get('settings/integrations/north-data')
|
||||
@ApiOperation({ summary: 'North Data Einstellungen abrufen' })
|
||||
async getSettings(@CurrentUser() user: JwtPayload) {
|
||||
const settings = await this.enrichmentService.getNorthDataSettings(
|
||||
user.tenantId!,
|
||||
);
|
||||
return singleResponse({
|
||||
...settings,
|
||||
apiKey: settings.apiKey
|
||||
? `****${settings.apiKey.slice(-4)}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings/integrations/north-data')
|
||||
@ApiOperation({ summary: 'North Data Einstellungen aktualisieren' })
|
||||
async updateSettings(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdateNorthDataSettingsDto,
|
||||
) {
|
||||
const settings = await this.enrichmentService.updateNorthDataSettings(
|
||||
user.tenantId!,
|
||||
dto,
|
||||
);
|
||||
return singleResponse({
|
||||
...settings,
|
||||
apiKey: settings.apiKey
|
||||
? `****${settings.apiKey.slice(-4)}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
packages/crm-service/src/enrichment/enrichment.module.ts
Normal file
16
packages/crm-service/src/enrichment/enrichment.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { EnrichmentController } from './enrichment.controller';
|
||||
import { EnrichmentService } from './enrichment.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({
|
||||
timeout: 15000,
|
||||
}),
|
||||
],
|
||||
controllers: [EnrichmentController],
|
||||
providers: [EnrichmentService],
|
||||
exports: [EnrichmentService],
|
||||
})
|
||||
export class EnrichmentModule {}
|
||||
307
packages/crm-service/src/enrichment/enrichment.service.ts
Normal file
307
packages/crm-service/src/enrichment/enrichment.service.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import {
|
||||
EnrichmentResponse,
|
||||
EnrichmentSuggestion,
|
||||
} from './dto/enrich-response.dto';
|
||||
import {
|
||||
NorthDataSettings,
|
||||
UpdateNorthDataSettingsDto,
|
||||
} from './dto/enrichment-settings.dto';
|
||||
|
||||
const NORTH_DATA_KEY = (tenantId: string) =>
|
||||
`crm:${tenantId}:integrations:north_data`;
|
||||
|
||||
@Injectable()
|
||||
export class EnrichmentService {
|
||||
private readonly logger = new Logger(EnrichmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly httpService: HttpService,
|
||||
) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Unternehmen anreichern (Suggestion-Only)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async enrichCompany(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
): Promise<EnrichmentResponse> {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const sources: string[] = [];
|
||||
const suggestions: Record<string, EnrichmentSuggestion> = {};
|
||||
|
||||
const northDataSettings = await this.getNorthDataSettings(tenantId);
|
||||
|
||||
// Parallele Abfragen
|
||||
const [registerResult, northDataResult] = await Promise.allSettled([
|
||||
this.queryUnternehmensregister(company.name, company.city ?? undefined),
|
||||
northDataSettings.enabled && northDataSettings.apiKey
|
||||
? this.queryNorthData(company.name, northDataSettings.apiKey)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Unternehmensregister.de Ergebnisse verarbeiten
|
||||
if (registerResult.status === 'fulfilled' && registerResult.value) {
|
||||
sources.push('unternehmensregister.de');
|
||||
const data = registerResult.value;
|
||||
|
||||
this.addSuggestion(suggestions, 'tradeRegisterNumber', company.tradeRegisterNumber, data.registerNumber, 'unternehmensregister.de');
|
||||
this.addSuggestion(suggestions, 'registerCourt', company.registerCourt, data.registerCourt, 'unternehmensregister.de');
|
||||
this.addSuggestion(suggestions, 'street', company.street, data.street, 'unternehmensregister.de');
|
||||
this.addSuggestion(suggestions, 'zip', company.zip, data.zip, 'unternehmensregister.de');
|
||||
this.addSuggestion(suggestions, 'city', company.city, data.city, 'unternehmensregister.de');
|
||||
} else if (registerResult.status === 'rejected') {
|
||||
warnings.push(`Unternehmensregister: ${String(registerResult.reason)}`);
|
||||
}
|
||||
|
||||
// North Data Ergebnisse verarbeiten
|
||||
if (northDataResult.status === 'fulfilled' && northDataResult.value) {
|
||||
sources.push('northdata.de');
|
||||
const data = northDataResult.value;
|
||||
|
||||
this.addSuggestion(suggestions, 'name', company.name, data.name, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'vatId', company.vatId, data.vatId, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'website', company.website, data.website, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'phone', company.phone, data.phone, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'street', company.street, data.street, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'zip', company.zip, data.zip, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'city', company.city, data.city, 'northdata.de');
|
||||
this.addSuggestion(suggestions, 'industry', company.industry, data.industry, 'northdata.de');
|
||||
} else if (northDataResult.status === 'rejected' && northDataSettings.enabled) {
|
||||
warnings.push(`North Data: ${String(northDataResult.reason)}`);
|
||||
} else if (!northDataSettings.enabled) {
|
||||
warnings.push('North Data Integration ist nicht aktiviert.');
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
warnings.push('Keine Datenquelle konnte abgefragt werden.');
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
companyName: company.name,
|
||||
sources,
|
||||
suggestions,
|
||||
enrichedAt: new Date().toISOString(),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Unternehmensregister.de Abfrage (kostenfrei)
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async queryUnternehmensregister(
|
||||
companyName: string,
|
||||
city?: string,
|
||||
): Promise<{
|
||||
registerNumber: string | null;
|
||||
registerCourt: string | null;
|
||||
street: string | null;
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const searchUrl = 'https://www.unternehmensregister.de/trxweb/api/search';
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(searchUrl, {
|
||||
params: {
|
||||
keyword: companyName,
|
||||
...(city ? { location: city } : {}),
|
||||
},
|
||||
timeout: 10000,
|
||||
}),
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
return {
|
||||
registerNumber: data.registerNumber ?? null,
|
||||
registerCourt: data.registerCourt ?? null,
|
||||
street: data.street ?? null,
|
||||
zip: data.zip ?? null,
|
||||
city: data.city ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Unternehmensregister-Abfrage fehlgeschlagen fuer "${companyName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw new Error('Unternehmensregister nicht erreichbar');
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// North Data API Abfrage (kostenpflichtig)
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async queryNorthData(
|
||||
companyName: string,
|
||||
apiKey: string,
|
||||
): Promise<{
|
||||
name: string | null;
|
||||
vatId: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
street: string | null;
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
industry: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
const baseUrl = this.config.get<string>(
|
||||
'NORTH_DATA_API_URL',
|
||||
'https://www.northdata.de/_api',
|
||||
);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${baseUrl}/company/v1/company`, {
|
||||
params: {
|
||||
name: companyName,
|
||||
address: 'DE',
|
||||
},
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
timeout: 15000,
|
||||
}),
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
return {
|
||||
name: data.name?.current ?? null,
|
||||
vatId: data.vatId ?? null,
|
||||
website: data.website ?? null,
|
||||
phone: data.phone ?? null,
|
||||
street: data.address?.street ?? null,
|
||||
zip: data.address?.postalCode ?? null,
|
||||
city: data.address?.city ?? null,
|
||||
industry: data.industry?.description ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`North Data Abfrage fehlgeschlagen fuer "${companyName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw new Error('North Data API nicht erreichbar');
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Helfer: Suggestion hinzufuegen (nur wenn suggested != current)
|
||||
// --------------------------------------------------------
|
||||
|
||||
private addSuggestion(
|
||||
suggestions: Record<string, EnrichmentSuggestion>,
|
||||
field: string,
|
||||
current: string | null | undefined,
|
||||
suggested: string | null | undefined,
|
||||
source: string,
|
||||
): void {
|
||||
if (!suggested) return;
|
||||
|
||||
const currentVal = current ?? null;
|
||||
const suggestedVal = suggested;
|
||||
|
||||
// Nur hinzufuegen wenn current leer oder verschieden
|
||||
if (currentVal !== suggestedVal) {
|
||||
// Erste Quelle gewinnt bei Duplikaten
|
||||
if (!suggestions[field]) {
|
||||
suggestions[field] = {
|
||||
current: currentVal,
|
||||
suggested: suggestedVal,
|
||||
source,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// North Data Settings (Redis)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async getNorthDataSettings(tenantId: string): Promise<NorthDataSettings> {
|
||||
const key = NORTH_DATA_KEY(tenantId);
|
||||
const raw = await this.redis.get(key);
|
||||
|
||||
if (!raw) {
|
||||
const envKey = this.config.get<string>('NORTH_DATA_API_KEY');
|
||||
return {
|
||||
apiKey: envKey ?? null,
|
||||
enabled: !!envKey,
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as NorthDataSettings;
|
||||
}
|
||||
|
||||
async updateNorthDataSettings(
|
||||
tenantId: string,
|
||||
dto: UpdateNorthDataSettingsDto,
|
||||
): Promise<NorthDataSettings> {
|
||||
const current = await this.getNorthDataSettings(tenantId);
|
||||
|
||||
const updated: NorthDataSettings = {
|
||||
apiKey: dto.apiKey !== undefined ? dto.apiKey : current.apiKey,
|
||||
enabled: dto.enabled !== undefined ? dto.enabled : current.enabled,
|
||||
};
|
||||
|
||||
const key = NORTH_DATA_KEY(tenantId);
|
||||
await this.redis.set(key, JSON.stringify(updated));
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Health Check
|
||||
// --------------------------------------------------------
|
||||
|
||||
async isHealthy(): Promise<'up' | 'down' | 'unconfigured'> {
|
||||
const envKey = this.config.get<string>('NORTH_DATA_API_KEY');
|
||||
if (!envKey) return 'unconfigured';
|
||||
|
||||
try {
|
||||
const baseUrl = this.config.get<string>(
|
||||
'NORTH_DATA_API_URL',
|
||||
'https://www.northdata.de/_api',
|
||||
);
|
||||
await firstValueFrom(
|
||||
this.httpService.get(`${baseUrl}/health`, {
|
||||
headers: { 'X-Api-Key': envKey },
|
||||
timeout: 5000,
|
||||
}),
|
||||
);
|
||||
return 'up';
|
||||
} catch {
|
||||
return 'down';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Public } from '../common/decorators/public.decorator';
|
|||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { LexwareClientService } from '../lexware/lexware-client.service';
|
||||
import { EnrichmentService } from '../enrichment/enrichment.service';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'error';
|
||||
|
|
@ -14,6 +15,7 @@ interface HealthResponse {
|
|||
database: 'up' | 'down';
|
||||
redis: 'up' | 'down';
|
||||
lexware: 'up' | 'down' | 'unconfigured';
|
||||
enrichment: 'up' | 'down' | 'unconfigured';
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -25,16 +27,20 @@ export class HealthController {
|
|||
private readonly redis: RedisService,
|
||||
@Optional() @Inject(LexwareClientService)
|
||||
private readonly lexwareClient?: LexwareClientService,
|
||||
@Optional() @Inject(EnrichmentService)
|
||||
private readonly enrichmentService?: EnrichmentService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
||||
async check(): Promise<HealthResponse> {
|
||||
const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([
|
||||
const [dbStatus, redisStatus, lexwareStatus, enrichmentStatus] =
|
||||
await Promise.allSettled([
|
||||
this.checkDatabase(),
|
||||
this.checkRedis(),
|
||||
this.checkLexware(),
|
||||
this.checkEnrichment(),
|
||||
]);
|
||||
|
||||
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
|
||||
|
|
@ -43,19 +49,26 @@ export class HealthController {
|
|||
lexwareStatus.status === 'fulfilled'
|
||||
? lexwareStatus.value
|
||||
: 'down';
|
||||
const enrichmentResult: 'up' | 'down' | 'unconfigured' =
|
||||
enrichmentStatus.status === 'fulfilled'
|
||||
? enrichmentStatus.value
|
||||
: 'down';
|
||||
|
||||
// Lexware "unconfigured" ist kein Fehler
|
||||
const allUp = dbOk && redisOk && lexwareResult !== 'down';
|
||||
// "unconfigured" ist kein Fehler
|
||||
const allUp = dbOk && redisOk
|
||||
&& lexwareResult !== 'down'
|
||||
&& enrichmentResult !== 'down';
|
||||
|
||||
return {
|
||||
status: allUp ? 'ok' : 'error',
|
||||
service: 'crm-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.0',
|
||||
version: '0.3.0',
|
||||
services: {
|
||||
database: dbOk ? 'up' : 'down',
|
||||
redis: redisOk ? 'up' : 'down',
|
||||
lexware: lexwareResult,
|
||||
enrichment: enrichmentResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -82,4 +95,9 @@ export class HealthController {
|
|||
if (!this.lexwareClient) return 'unconfigured';
|
||||
return this.lexwareClient.isHealthy();
|
||||
}
|
||||
|
||||
private async checkEnrichment(): Promise<'up' | 'down' | 'unconfigured'> {
|
||||
if (!this.enrichmentService) return 'unconfigured';
|
||||
return this.enrichmentService.isHealthy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
import { EnrichmentModule } from '../enrichment/enrichment.module';
|
||||
|
||||
@Module({
|
||||
imports: [LexwareModule],
|
||||
imports: [LexwareModule, EnrichmentModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
52
packages/crm-service/src/import/dto/import-execute.dto.ts
Normal file
52
packages/crm-service/src/import/dto/import-execute.dto.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ImportEntityType } from './import-preview.dto';
|
||||
|
||||
export enum DuplicateStrategy {
|
||||
SKIP = 'SKIP',
|
||||
UPDATE = 'UPDATE',
|
||||
MARK = 'MARK',
|
||||
}
|
||||
|
||||
export class FieldMappingDto {
|
||||
@ApiProperty({ description: 'Spaltenname aus der Datei' })
|
||||
@IsString()
|
||||
sourceColumn!: string;
|
||||
|
||||
@ApiProperty({ description: 'Zielfeld im CRM (z.B. firstName, email, phone)' })
|
||||
@IsString()
|
||||
targetField!: string;
|
||||
}
|
||||
|
||||
export class ImportExecuteDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Import-ID aus der Preview' })
|
||||
@IsUUID()
|
||||
importId!: string;
|
||||
|
||||
@ApiProperty({ enum: ImportEntityType })
|
||||
@IsEnum(ImportEntityType)
|
||||
entityType!: ImportEntityType;
|
||||
|
||||
@ApiProperty({ type: [FieldMappingDto], description: 'Feld-Zuordnungen' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FieldMappingDto)
|
||||
mapping!: FieldMappingDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: DuplicateStrategy,
|
||||
default: DuplicateStrategy.SKIP,
|
||||
description: 'Strategie bei Duplikaten (E-Mail-Abgleich)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(DuplicateStrategy)
|
||||
duplicateStrategy?: DuplicateStrategy = DuplicateStrategy.SKIP;
|
||||
}
|
||||
23
packages/crm-service/src/import/dto/import-preview.dto.ts
Normal file
23
packages/crm-service/src/import/dto/import-preview.dto.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum ImportEntityType {
|
||||
CONTACT = 'contact',
|
||||
COMPANY = 'company',
|
||||
}
|
||||
|
||||
export class ImportPreviewQueryDto {
|
||||
@ApiProperty({
|
||||
enum: ImportEntityType,
|
||||
description: 'Ziel-Entitaet fuer den Import',
|
||||
})
|
||||
@IsEnum(ImportEntityType)
|
||||
entityType!: ImportEntityType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'CSV-Trennzeichen (Standard: automatische Erkennung)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
delimiter?: string;
|
||||
}
|
||||
103
packages/crm-service/src/import/import.controller.ts
Normal file
103
packages/crm-service/src/import/import.controller.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import { singleResponse } from '../common/dto/pagination.dto';
|
||||
import { ImportService } from './import.service';
|
||||
import { ImportPreviewQueryDto } from './dto/import-preview.dto';
|
||||
import { ImportExecuteDto } from './dto/import-execute.dto';
|
||||
|
||||
@ApiTags('Import (CSV/Excel)')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(TenantGuard)
|
||||
@Controller('import')
|
||||
export class ImportController {
|
||||
constructor(private readonly importService: ImportService) {}
|
||||
|
||||
@Post('preview')
|
||||
@ApiOperation({ summary: 'Datei-Vorschau (CSV/XLSX) mit Spalten-Erkennung' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
entityType: { type: 'string', enum: ['contact', 'company'] },
|
||||
delimiter: { type: 'string' },
|
||||
},
|
||||
required: ['file', 'entityType'],
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
||||
fileFilter: (
|
||||
_req: unknown,
|
||||
file: Express.Multer.File,
|
||||
cb: (error: Error | null, acceptFile: boolean) => void,
|
||||
) => {
|
||||
const allowed = [
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(
|
||||
new BadRequestException(
|
||||
`Dateityp "${file.mimetype}" nicht erlaubt. Nur CSV und Excel.`,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
async preview(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Query() query: ImportPreviewQueryDto,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Keine Datei hochgeladen');
|
||||
}
|
||||
|
||||
const result = await this.importService.preview(
|
||||
file,
|
||||
query.entityType,
|
||||
query.delimiter,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Post('execute')
|
||||
@ApiOperation({ summary: 'Import ausfuehren (nach Preview)' })
|
||||
async execute(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ImportExecuteDto,
|
||||
) {
|
||||
const result = await this.importService.execute(
|
||||
user.tenantId!,
|
||||
user.sub,
|
||||
dto,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
}
|
||||
9
packages/crm-service/src/import/import.module.ts
Normal file
9
packages/crm-service/src/import/import.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ImportController],
|
||||
providers: [ImportService],
|
||||
})
|
||||
export class ImportModule {}
|
||||
509
packages/crm-service/src/import/import.service.ts
Normal file
509
packages/crm-service/src/import/import.service.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { ImportEntityType } from './dto/import-preview.dto';
|
||||
import {
|
||||
ImportExecuteDto,
|
||||
DuplicateStrategy,
|
||||
FieldMappingDto,
|
||||
} from './dto/import-execute.dto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import csvParser from 'csv-parser';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
const MAX_ROWS = 5000;
|
||||
const PREVIEW_ROWS = 10;
|
||||
|
||||
const TARGET_FIELDS: Record<ImportEntityType, string[]> = {
|
||||
[ImportEntityType.CONTACT]: [
|
||||
'firstName', 'lastName', 'email', 'phone', 'mobile',
|
||||
'companyName', 'position', 'department', 'website',
|
||||
'street', 'zip', 'city', 'state', 'country',
|
||||
'notes', 'tags', 'source', 'linkedinUrl',
|
||||
],
|
||||
[ImportEntityType.COMPANY]: [
|
||||
'name', 'email', 'phone', 'website', 'industry',
|
||||
'street', 'zip', 'city', 'state', 'country',
|
||||
'vatId', 'taxId', 'tradeRegisterNumber', 'registerCourt',
|
||||
'notes', 'tags',
|
||||
],
|
||||
};
|
||||
|
||||
interface ParsedFile {
|
||||
columns: string[];
|
||||
rows: Record<string, string>[];
|
||||
totalRows: number;
|
||||
format: 'csv' | 'xlsx';
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
row: number;
|
||||
field: string;
|
||||
value: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
private readonly logger = new Logger(ImportService.name);
|
||||
|
||||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Preview: Datei parsen und Vorschau zurueckgeben
|
||||
// --------------------------------------------------------
|
||||
|
||||
async preview(
|
||||
file: Express.Multer.File,
|
||||
entityType: ImportEntityType,
|
||||
delimiter?: string,
|
||||
) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.csv', '.xlsx', '.xls'].includes(ext)) {
|
||||
throw new BadRequestException(
|
||||
'Nicht unterstuetztes Dateiformat. Erlaubt: .csv, .xlsx, .xls',
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = ext === '.csv'
|
||||
? await this.parseCsv(file.buffer, delimiter)
|
||||
: this.parseExcel(file.buffer);
|
||||
|
||||
if (parsed.totalRows > MAX_ROWS) {
|
||||
throw new BadRequestException(
|
||||
`Datei enthaelt ${parsed.totalRows} Zeilen. Maximum: ${MAX_ROWS}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Temp-Datei speichern (GDPR: wird nach Execute geloescht)
|
||||
const importId = uuidv4();
|
||||
const tmpPath = path.join(os.tmpdir(), `crm-import-${importId}${ext}`);
|
||||
fs.writeFileSync(tmpPath, file.buffer);
|
||||
|
||||
return {
|
||||
importId,
|
||||
format: parsed.format,
|
||||
columns: parsed.columns,
|
||||
rows: parsed.rows.slice(0, PREVIEW_ROWS),
|
||||
totalRows: parsed.totalRows,
|
||||
availableTargetFields: TARGET_FIELDS[entityType],
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Execute: Import ausfuehren
|
||||
// --------------------------------------------------------
|
||||
|
||||
async execute(tenantId: string, userId: string, dto: ImportExecuteDto) {
|
||||
const tmpDir = os.tmpdir();
|
||||
const possibleExts = ['.csv', '.xlsx', '.xls'];
|
||||
let tmpPath: string | null = null;
|
||||
let ext = '';
|
||||
|
||||
for (const e of possibleExts) {
|
||||
const candidate = path.join(tmpDir, `crm-import-${dto.importId}${e}`);
|
||||
if (fs.existsSync(candidate)) {
|
||||
tmpPath = candidate;
|
||||
ext = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tmpPath) {
|
||||
throw new NotFoundException(
|
||||
'Import-Datei nicht gefunden. Bitte erneut hochladen (Preview abgelaufen).',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = fs.readFileSync(tmpPath);
|
||||
const parsed = ext === '.csv'
|
||||
? await this.parseCsv(buffer)
|
||||
: this.parseExcel(buffer);
|
||||
|
||||
this.validateMapping(dto.mapping, dto.entityType);
|
||||
|
||||
const result = dto.entityType === ImportEntityType.CONTACT
|
||||
? await this.importContacts(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP)
|
||||
: await this.importCompanies(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP);
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
// GDPR: Temp-Datei immer loeschen
|
||||
try {
|
||||
fs.unlinkSync(tmpPath);
|
||||
this.logger.debug(`Temp-Datei geloescht: ${tmpPath}`);
|
||||
} catch {
|
||||
this.logger.warn(`Temp-Datei konnte nicht geloescht werden: ${tmpPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Privat: CSV parsen
|
||||
// --------------------------------------------------------
|
||||
|
||||
private parseCsv(buffer: Buffer, delimiter?: string): Promise<ParsedFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rows: Record<string, string>[] = [];
|
||||
let columns: string[] = [];
|
||||
|
||||
const stream = Readable.from(buffer);
|
||||
stream
|
||||
.pipe(csvParser({
|
||||
separator: delimiter || undefined,
|
||||
mapHeaders: ({ header }: { header: string }) => header.trim(),
|
||||
}))
|
||||
.on('headers', (headers: string[]) => {
|
||||
columns = headers;
|
||||
})
|
||||
.on('data', (row: Record<string, string>) => {
|
||||
if (rows.length < MAX_ROWS) {
|
||||
rows.push(row);
|
||||
}
|
||||
})
|
||||
.on('end', () => {
|
||||
resolve({ columns, rows, totalRows: rows.length, format: 'csv' });
|
||||
})
|
||||
.on('error', (err: Error) => {
|
||||
reject(new BadRequestException(`CSV-Parse-Fehler: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Privat: Excel parsen
|
||||
// --------------------------------------------------------
|
||||
|
||||
private parseExcel(buffer: Buffer): ParsedFile {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
throw new BadRequestException('Excel-Datei enthaelt keine Arbeitsblaetter');
|
||||
}
|
||||
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
|
||||
defval: '',
|
||||
raw: false,
|
||||
});
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
throw new BadRequestException('Excel-Datei enthaelt keine Daten');
|
||||
}
|
||||
|
||||
const columns = Object.keys(jsonData[0]);
|
||||
const rows = jsonData.slice(0, MAX_ROWS);
|
||||
|
||||
return { columns, rows, totalRows: jsonData.length, format: 'xlsx' };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Privat: Mapping validieren
|
||||
// --------------------------------------------------------
|
||||
|
||||
private validateMapping(
|
||||
mapping: FieldMappingDto[],
|
||||
entityType: ImportEntityType,
|
||||
): void {
|
||||
const allowed = TARGET_FIELDS[entityType];
|
||||
for (const m of mapping) {
|
||||
if (!allowed.includes(m.targetField)) {
|
||||
throw new BadRequestException(
|
||||
`Ungueltiges Zielfeld "${m.targetField}" fuer ${entityType}. Erlaubt: ${allowed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Privat: Kontakte importieren
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async importContacts(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
rows: Record<string, string>[],
|
||||
mapping: FieldMappingDto[],
|
||||
duplicateStrategy: DuplicateStrategy,
|
||||
) {
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const errors: ImportError[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rowNum = i + 2; // +2 weil Header = Zeile 1
|
||||
try {
|
||||
const mapped = this.mapRow(rows[i], mapping);
|
||||
const email = (mapped['email'] ?? '').trim().toLowerCase();
|
||||
|
||||
// Duplikat pruefen
|
||||
let existingId: string | null = null;
|
||||
if (email) {
|
||||
const existing = await this.prisma.contact.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
email: { equals: email, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
existingId = existing?.id ?? null;
|
||||
}
|
||||
|
||||
if (existingId) {
|
||||
switch (duplicateStrategy) {
|
||||
case DuplicateStrategy.SKIP:
|
||||
skipped++;
|
||||
continue;
|
||||
case DuplicateStrategy.UPDATE:
|
||||
await this.prisma.contact.update({
|
||||
where: { id: existingId },
|
||||
data: {
|
||||
...this.buildContactData(mapped),
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
continue;
|
||||
case DuplicateStrategy.MARK:
|
||||
mapped['_isDuplicate'] = 'true';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : [];
|
||||
if (mapped['tags']) {
|
||||
tags.push(...mapped['tags'].split(',').map((t: string) => t.trim()));
|
||||
}
|
||||
|
||||
await this.prisma.contact.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdBy: userId,
|
||||
firstName: mapped['firstName'] ?? null,
|
||||
lastName: mapped['lastName'] ?? null,
|
||||
email: mapped['email'] ?? null,
|
||||
phone: mapped['phone'] ?? null,
|
||||
mobile: mapped['mobile'] ?? null,
|
||||
companyName: mapped['companyName'] ?? null,
|
||||
position: mapped['position'] ?? null,
|
||||
department: mapped['department'] ?? null,
|
||||
website: mapped['website'] ?? null,
|
||||
linkedinUrl: mapped['linkedinUrl'] ?? null,
|
||||
street: mapped['street'] ?? null,
|
||||
zip: mapped['zip'] ?? null,
|
||||
city: mapped['city'] ?? null,
|
||||
state: mapped['state'] ?? null,
|
||||
country: mapped['country'] ?? 'DE',
|
||||
notes: mapped['notes'] ?? null,
|
||||
tags,
|
||||
source: 'IMPORT',
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
});
|
||||
created++;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: '',
|
||||
value: '',
|
||||
message: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
skipped,
|
||||
errors: errors.length,
|
||||
totalProcessed: rows.length,
|
||||
errorDetails: errors.slice(0, 50),
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Privat: Unternehmen importieren
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async importCompanies(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
rows: Record<string, string>[],
|
||||
mapping: FieldMappingDto[],
|
||||
duplicateStrategy: DuplicateStrategy,
|
||||
) {
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const errors: ImportError[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rowNum = i + 2;
|
||||
try {
|
||||
const mapped = this.mapRow(rows[i], mapping);
|
||||
const email = (mapped['email'] ?? '').trim().toLowerCase();
|
||||
|
||||
let existingId: string | null = null;
|
||||
if (email) {
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
email: { equals: email, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
existingId = existing?.id ?? null;
|
||||
}
|
||||
|
||||
if (existingId) {
|
||||
switch (duplicateStrategy) {
|
||||
case DuplicateStrategy.SKIP:
|
||||
skipped++;
|
||||
continue;
|
||||
case DuplicateStrategy.UPDATE:
|
||||
await this.prisma.company.update({
|
||||
where: { id: existingId },
|
||||
data: {
|
||||
...this.buildCompanyData(mapped),
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
continue;
|
||||
case DuplicateStrategy.MARK:
|
||||
mapped['_isDuplicate'] = 'true';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const name = mapped['name'] ?? '';
|
||||
if (!name) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'name',
|
||||
value: '',
|
||||
message: 'Unternehmensname ist Pflichtfeld',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : [];
|
||||
if (mapped['tags']) {
|
||||
tags.push(...mapped['tags'].split(',').map((t: string) => t.trim()));
|
||||
}
|
||||
|
||||
await this.prisma.company.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdBy: userId,
|
||||
name,
|
||||
email: mapped['email'] ?? null,
|
||||
phone: mapped['phone'] ?? null,
|
||||
website: mapped['website'] ?? null,
|
||||
industry: mapped['industry'] ?? null,
|
||||
vatId: mapped['vatId'] ?? null,
|
||||
taxId: mapped['taxId'] ?? null,
|
||||
tradeRegisterNumber: mapped['tradeRegisterNumber'] ?? null,
|
||||
registerCourt: mapped['registerCourt'] ?? null,
|
||||
street: mapped['street'] ?? null,
|
||||
zip: mapped['zip'] ?? null,
|
||||
city: mapped['city'] ?? null,
|
||||
state: mapped['state'] ?? null,
|
||||
country: mapped['country'] ?? 'DE',
|
||||
notes: mapped['notes'] ?? null,
|
||||
tags,
|
||||
owners: {
|
||||
create: { tenantId, userId, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
});
|
||||
created++;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: '',
|
||||
value: '',
|
||||
message: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
skipped,
|
||||
errors: errors.length,
|
||||
totalProcessed: rows.length,
|
||||
errorDetails: errors.slice(0, 50),
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Helfer
|
||||
// --------------------------------------------------------
|
||||
|
||||
private mapRow(
|
||||
row: Record<string, string>,
|
||||
mapping: FieldMappingDto[],
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const m of mapping) {
|
||||
const value = row[m.sourceColumn];
|
||||
if (value !== undefined && value !== '') {
|
||||
result[m.targetField] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildContactData(mapped: Record<string, string>) {
|
||||
return {
|
||||
...(mapped['firstName'] && { firstName: mapped['firstName'] }),
|
||||
...(mapped['lastName'] && { lastName: mapped['lastName'] }),
|
||||
...(mapped['email'] && { email: mapped['email'] }),
|
||||
...(mapped['phone'] && { phone: mapped['phone'] }),
|
||||
...(mapped['mobile'] && { mobile: mapped['mobile'] }),
|
||||
...(mapped['companyName'] && { companyName: mapped['companyName'] }),
|
||||
...(mapped['position'] && { position: mapped['position'] }),
|
||||
...(mapped['department'] && { department: mapped['department'] }),
|
||||
...(mapped['website'] && { website: mapped['website'] }),
|
||||
...(mapped['linkedinUrl'] && { linkedinUrl: mapped['linkedinUrl'] }),
|
||||
...(mapped['street'] && { street: mapped['street'] }),
|
||||
...(mapped['zip'] && { zip: mapped['zip'] }),
|
||||
...(mapped['city'] && { city: mapped['city'] }),
|
||||
...(mapped['state'] && { state: mapped['state'] }),
|
||||
...(mapped['country'] && { country: mapped['country'] }),
|
||||
...(mapped['notes'] && { notes: mapped['notes'] }),
|
||||
};
|
||||
}
|
||||
|
||||
private buildCompanyData(mapped: Record<string, string>) {
|
||||
return {
|
||||
...(mapped['name'] && { name: mapped['name'] }),
|
||||
...(mapped['email'] && { email: mapped['email'] }),
|
||||
...(mapped['phone'] && { phone: mapped['phone'] }),
|
||||
...(mapped['website'] && { website: mapped['website'] }),
|
||||
...(mapped['industry'] && { industry: mapped['industry'] }),
|
||||
...(mapped['vatId'] && { vatId: mapped['vatId'] }),
|
||||
...(mapped['taxId'] && { taxId: mapped['taxId'] }),
|
||||
...(mapped['tradeRegisterNumber'] && { tradeRegisterNumber: mapped['tradeRegisterNumber'] }),
|
||||
...(mapped['registerCourt'] && { registerCourt: mapped['registerCourt'] }),
|
||||
...(mapped['street'] && { street: mapped['street'] }),
|
||||
...(mapped['zip'] && { zip: mapped['zip'] }),
|
||||
...(mapped['city'] && { city: mapped['city'] }),
|
||||
...(mapped['state'] && { state: mapped['state'] }),
|
||||
...(mapped['country'] && { country: mapped['country'] }),
|
||||
...(mapped['notes'] && { notes: mapped['notes'] }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ import {
|
|||
IsArray,
|
||||
ValidateNested,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
|
@ -29,6 +31,18 @@ export class CreatePipelineStageDto {
|
|||
@IsString()
|
||||
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: 0,
|
||||
description: 'Abschlusswahrscheinlichkeit (0.00 – 1.00)',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
probability?: number;
|
||||
}
|
||||
|
||||
export class CreatePipelineDto {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import {
|
|||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
|
@ -26,4 +28,15 @@ export class UpdateStageDto {
|
|||
@IsString()
|
||||
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Abschlusswahrscheinlichkeit (0.00 – 1.00)',
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
probability?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export class PipelinesController {
|
|||
dto.name,
|
||||
dto.sortOrder ?? 0,
|
||||
dto.color,
|
||||
dto.probability,
|
||||
);
|
||||
return singleResponse(stage);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export class PipelinesService {
|
|||
name: stage.name,
|
||||
sortOrder: stage.sortOrder ?? index,
|
||||
color: stage.color ?? '#6B7280',
|
||||
probability: stage.probability ?? 0,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
|
|
@ -86,6 +87,7 @@ export class PipelinesService {
|
|||
name: string,
|
||||
sortOrder: number,
|
||||
color?: string,
|
||||
probability?: number,
|
||||
) {
|
||||
await this.findOne(tenantId, pipelineId);
|
||||
|
||||
|
|
@ -95,6 +97,7 @@ export class PipelinesService {
|
|||
name,
|
||||
sortOrder,
|
||||
color: color ?? '#6B7280',
|
||||
probability: probability ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -121,6 +124,7 @@ export class PipelinesService {
|
|||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
...(dto.color !== undefined && { color: dto.color }),
|
||||
...(dto.probability !== undefined && { probability: dto.probability }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue