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
|
- ./packages/crm-service:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- ./.keys/jwt-public.pem:/app/keys/jwt-public.pem:ro
|
- ./.keys/jwt-public.pem:/app/keys/jwt-public.pem:ro
|
||||||
|
- ./uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- insight-web
|
- insight-web
|
||||||
- insight-db
|
- 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.
|
**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/
|
src/
|
||||||
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
||||||
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule
|
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)
|
prisma/ — CrmPrismaService (eigener Client)
|
||||||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
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)
|
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)
|
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)
|
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK, FOLLOWUP; contactId+companyId optional)
|
||||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
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
|
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)
|
owners/ — Shared Owner-Service (Contact/Company/Deal Owners, Upsert, Rollen)
|
||||||
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
events/ — CRM Event Publisher (Redis Pub/Sub) + Activity Due-Soon Scheduler
|
||||||
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
industries/ — CRUD: Branchen (admin-konfigurierbar, mit Farbe)
|
||||||
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
account-types/ — CRUD: Kontotypen (admin-konfigurierbar)
|
||||||
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
relationship-types/ — CRUD: Beziehungstypen (admin-konfigurierbar)
|
||||||
company-relationships/ — Company-zu-Company Beziehungen (N:M, bidirektional)
|
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/ — Lexware Office Integration
|
||||||
lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
|
lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
|
||||||
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
|
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
|
- **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
|
- **Activity** — Aktivitaeten verknuepft mit Kontakten UND/ODER Companies (contactId + companyId beide optional, min. 1) + FOLLOWUP-Typ
|
||||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
- **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
|
- **Deal** — Vorgaenge mit dealVouchers-Relation, Owner m:n, LostReason/LostReasonText, Events
|
||||||
- **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER)
|
- **ContactEmail** — Multi-Value E-Mail-Adressen (Contact/Company, Typ: WORK/PERSONAL/OTHER)
|
||||||
- **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX)
|
- **ContactPhone** — Multi-Value Telefonnummern (Contact/Company, Typ: OFFICE/MOBILE/FAX)
|
||||||
|
|
@ -71,7 +74,8 @@ packages/crm-service/
|
||||||
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
- **AccountType** — Admin-konfigurierbare Kontotypen (unique pro Tenant)
|
||||||
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
- **RelationshipType** — Admin-konfigurierbare Beziehungstypen (unique pro Tenant)
|
||||||
- **CompanyRelationship** — N:M Company-zu-Company Beziehungen mit Typ und Notizen
|
- **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
|
- **LexwareVoucher** — Gecachte Belege aus Lexware Office
|
||||||
- **DealVoucher** — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
- **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]
|
- **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 |
|
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
||||||
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
|
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
|
||||||
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
| 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/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** | | |
|
| **Custom Fields** | | |
|
||||||
| POST | /api/v1/crm/custom-fields | Feld-Definition erstellen |
|
| POST | /api/v1/crm/custom-fields | Feld-Definition erstellen |
|
||||||
| GET | /api/v1/crm/custom-fields?entityType=PERSON | Definitionen auflisten (nach Entity-Typ) |
|
| 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
|
- `20260311_add_company_detail_overhaul` — Company Detail Overhaul
|
||||||
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
- `20260312_phase1_schema_expansion` — Phase 1: Enums, Multi-Value, Owner, LostReason
|
||||||
- `20260312_phase2_custom_fields` — Phase 2.1: Custom Fields (Definitionen + Werte)
|
- `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
|
### 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
|
2. Container neu bauen und deployen
|
||||||
3. Frontend: Custom Fields Admin-UI + Entity-Integration
|
3. Frontend: Forecast-Widget, Import-UI, Enrichment-UI
|
||||||
4. Phase 2.2: Kontakt-Import (CSV, Excel, vCard)
|
4. Phase 2.5: Berechtigungsmodell (Sichtbarkeitsfilter)
|
||||||
5. Phase 2.3: Forecast-Endpoint (Probability-Feld auf PipelineStage)
|
|
||||||
6. Phase 2.4: Firmendaten-Anreicherung (Data Enrichment)
|
|
||||||
7. 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-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"csv-parser": "^3.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
|
@ -37,6 +39,7 @@
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|
@ -2559,6 +2562,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "22.19.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
|
@ -3146,6 +3159,15 @@
|
||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||||
|
|
@ -3859,6 +3881,19 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -4104,6 +4139,15 @@
|
||||||
"node": ">= 0.12.0"
|
"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": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
"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": {
|
"node_modules/create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||||
|
|
@ -4360,6 +4416,18 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -5587,6 +5655,15 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
|
@ -9075,6 +9152,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
|
|
@ -10239,6 +10328,24 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
@ -10318,6 +10425,27 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,15 @@
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
"csv-parser": "^3.2.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.0",
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -410,12 +410,31 @@ model Contract {
|
||||||
|
|
||||||
// Relationen
|
// Relationen
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||||
|
files ContractFile[]
|
||||||
|
|
||||||
@@index([tenantId, companyId])
|
@@index([tenantId, companyId])
|
||||||
@@map("contracts")
|
@@map("contracts")
|
||||||
@@schema("app_crm")
|
@@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 {
|
enum ContractStatus {
|
||||||
DRAFT
|
DRAFT
|
||||||
ACTIVE
|
ACTIVE
|
||||||
|
|
@ -461,7 +480,8 @@ model PipelineStage {
|
||||||
pipelineId String @map("pipeline_id") @db.Uuid
|
pipelineId String @map("pipeline_id") @db.Uuid
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
color String @default("#6B7280") @db.VarChar(7)
|
color String @default("#6B7280") @db.VarChar(7)
|
||||||
|
probability Decimal @default(0) @map("probability") @db.Decimal(3, 2)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_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 { TradeEventsModule } from './trade-events/trade-events.module';
|
||||||
import { CrmEventsModule } from './events/crm-events.module';
|
import { CrmEventsModule } from './events/crm-events.module';
|
||||||
import { CustomFieldsModule } from './custom-fields/custom-fields.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -47,6 +50,9 @@ import { CustomFieldsModule } from './custom-fields/custom-fields.module';
|
||||||
TradeEventsModule,
|
TradeEventsModule,
|
||||||
CrmEventsModule,
|
CrmEventsModule,
|
||||||
CustomFieldsModule,
|
CustomFieldsModule,
|
||||||
|
ImportModule,
|
||||||
|
EnrichmentModule,
|
||||||
|
ContractsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,15 @@ export class EnvironmentVariables {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
LEXWARE_API_URL?: string;
|
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(
|
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 { CreateDealDto } from './dto/create-deal.dto';
|
||||||
import { UpdateDealDto } from './dto/update-deal.dto';
|
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||||
import { QueryDealsDto } from './dto/query-deals.dto';
|
import { QueryDealsDto } from './dto/query-deals.dto';
|
||||||
|
import { ForecastQueryDto } from './dto/forecast-query.dto';
|
||||||
import { AddOwnerDto } from '../common/dto/owner.dto';
|
import { AddOwnerDto } from '../common/dto/owner.dto';
|
||||||
import { OwnersService } from '../owners/owners.service';
|
import { OwnersService } from '../owners/owners.service';
|
||||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Vorgangsdetails abrufen' })
|
@ApiOperation({ summary: 'Vorgangsdetails abrufen' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@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 { CreateDealDto } from './dto/create-deal.dto';
|
||||||
import { UpdateDealDto } from './dto/update-deal.dto';
|
import { UpdateDealDto } from './dto/update-deal.dto';
|
||||||
import { QueryDealsDto } from './dto/query-deals.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 { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
||||||
|
|
@ -333,4 +334,123 @@ export class DealsService {
|
||||||
|
|
||||||
return this.prisma.deal.delete({ where: { id } });
|
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 { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
import { RedisService } from '../redis/redis.service';
|
import { RedisService } from '../redis/redis.service';
|
||||||
import { LexwareClientService } from '../lexware/lexware-client.service';
|
import { LexwareClientService } from '../lexware/lexware-client.service';
|
||||||
|
import { EnrichmentService } from '../enrichment/enrichment.service';
|
||||||
|
|
||||||
interface HealthResponse {
|
interface HealthResponse {
|
||||||
status: 'ok' | 'error';
|
status: 'ok' | 'error';
|
||||||
|
|
@ -14,6 +15,7 @@ interface HealthResponse {
|
||||||
database: 'up' | 'down';
|
database: 'up' | 'down';
|
||||||
redis: 'up' | 'down';
|
redis: 'up' | 'down';
|
||||||
lexware: 'up' | 'down' | 'unconfigured';
|
lexware: 'up' | 'down' | 'unconfigured';
|
||||||
|
enrichment: 'up' | 'down' | 'unconfigured';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,17 +27,21 @@ export class HealthController {
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
@Optional() @Inject(LexwareClientService)
|
@Optional() @Inject(LexwareClientService)
|
||||||
private readonly lexwareClient?: LexwareClientService,
|
private readonly lexwareClient?: LexwareClientService,
|
||||||
|
@Optional() @Inject(EnrichmentService)
|
||||||
|
private readonly enrichmentService?: EnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
||||||
async check(): Promise<HealthResponse> {
|
async check(): Promise<HealthResponse> {
|
||||||
const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([
|
const [dbStatus, redisStatus, lexwareStatus, enrichmentStatus] =
|
||||||
this.checkDatabase(),
|
await Promise.allSettled([
|
||||||
this.checkRedis(),
|
this.checkDatabase(),
|
||||||
this.checkLexware(),
|
this.checkRedis(),
|
||||||
]);
|
this.checkLexware(),
|
||||||
|
this.checkEnrichment(),
|
||||||
|
]);
|
||||||
|
|
||||||
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
|
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
|
||||||
const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value;
|
const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value;
|
||||||
|
|
@ -43,19 +49,26 @@ export class HealthController {
|
||||||
lexwareStatus.status === 'fulfilled'
|
lexwareStatus.status === 'fulfilled'
|
||||||
? lexwareStatus.value
|
? lexwareStatus.value
|
||||||
: 'down';
|
: 'down';
|
||||||
|
const enrichmentResult: 'up' | 'down' | 'unconfigured' =
|
||||||
|
enrichmentStatus.status === 'fulfilled'
|
||||||
|
? enrichmentStatus.value
|
||||||
|
: 'down';
|
||||||
|
|
||||||
// Lexware "unconfigured" ist kein Fehler
|
// "unconfigured" ist kein Fehler
|
||||||
const allUp = dbOk && redisOk && lexwareResult !== 'down';
|
const allUp = dbOk && redisOk
|
||||||
|
&& lexwareResult !== 'down'
|
||||||
|
&& enrichmentResult !== 'down';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: allUp ? 'ok' : 'error',
|
status: allUp ? 'ok' : 'error',
|
||||||
service: 'crm-service',
|
service: 'crm-service',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: '0.2.0',
|
version: '0.3.0',
|
||||||
services: {
|
services: {
|
||||||
database: dbOk ? 'up' : 'down',
|
database: dbOk ? 'up' : 'down',
|
||||||
redis: redisOk ? 'up' : 'down',
|
redis: redisOk ? 'up' : 'down',
|
||||||
lexware: lexwareResult,
|
lexware: lexwareResult,
|
||||||
|
enrichment: enrichmentResult,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -82,4 +95,9 @@ export class HealthController {
|
||||||
if (!this.lexwareClient) return 'unconfigured';
|
if (!this.lexwareClient) return 'unconfigured';
|
||||||
return this.lexwareClient.isHealthy();
|
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 { Module } from '@nestjs/common';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { LexwareModule } from '../lexware/lexware.module';
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
|
import { EnrichmentModule } from '../enrichment/enrichment.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LexwareModule],
|
imports: [LexwareModule, EnrichmentModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
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,
|
IsArray,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
Min,
|
Min,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
@ -29,6 +31,18 @@ export class CreatePipelineStageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
||||||
color?: string;
|
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 {
|
export class CreatePipelineDto {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import {
|
||||||
IsString,
|
IsString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
Min,
|
Min,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Matches,
|
Matches,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
@ -26,4 +28,15 @@ export class UpdateStageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
@Matches(/^#[0-9A-Fa-f]{6}$/)
|
||||||
color?: string;
|
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.name,
|
||||||
dto.sortOrder ?? 0,
|
dto.sortOrder ?? 0,
|
||||||
dto.color,
|
dto.color,
|
||||||
|
dto.probability,
|
||||||
);
|
);
|
||||||
return singleResponse(stage);
|
return singleResponse(stage);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export class PipelinesService {
|
||||||
name: stage.name,
|
name: stage.name,
|
||||||
sortOrder: stage.sortOrder ?? index,
|
sortOrder: stage.sortOrder ?? index,
|
||||||
color: stage.color ?? '#6B7280',
|
color: stage.color ?? '#6B7280',
|
||||||
|
probability: stage.probability ?? 0,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -86,6 +87,7 @@ export class PipelinesService {
|
||||||
name: string,
|
name: string,
|
||||||
sortOrder: number,
|
sortOrder: number,
|
||||||
color?: string,
|
color?: string,
|
||||||
|
probability?: number,
|
||||||
) {
|
) {
|
||||||
await this.findOne(tenantId, pipelineId);
|
await this.findOne(tenantId, pipelineId);
|
||||||
|
|
||||||
|
|
@ -95,6 +97,7 @@ export class PipelinesService {
|
||||||
name,
|
name,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
color: color ?? '#6B7280',
|
color: color ?? '#6B7280',
|
||||||
|
probability: probability ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +124,7 @@ export class PipelinesService {
|
||||||
...(dto.name !== undefined && { name: dto.name }),
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||||
...(dto.color !== undefined && { color: dto.color }),
|
...(dto.color !== undefined && { color: dto.color }),
|
||||||
|
...(dto.probability !== undefined && { probability: dto.probability }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue