mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +02:00
feat(crm): integrate Lexware Office for vouchers and contact sync
Adds complete Lexware Office integration to CRM service: - Rate-limited HTTP client (Token Bucket, 2 req/s) - Bidirectional contact sync (manual import + ERP-push) - Voucher caching (quotes, orders, invoices, credit notes) - Deal-voucher linking (m:n join table with audit) - Cron jobs: voucher refresh (4h), ERP push (30min) - Distributed locks via Redis for job deduplication - Health check extended with Lexware status - Prisma schema: LexwareVoucher, DealVoucher, VoucherType enum - Companies/Contacts/Deals services extended with Lexware data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
411a6bbbcb
commit
9d496d2e53
31 changed files with 3173 additions and 99 deletions
|
|
@ -24,6 +24,9 @@ services:
|
|||
- JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem
|
||||
- JWT_ISSUER=${JWT_ISSUER:-insight-platform}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59}
|
||||
# Lexware Office Integration (optional)
|
||||
- LEXWARE_API_KEY=${LEXWARE_API_KEY:-}
|
||||
- LEXWARE_API_URL=${LEXWARE_API_URL:-https://api.lexware.io}
|
||||
volumes:
|
||||
- ./packages/crm-service:/app
|
||||
- /app/node_modules
|
||||
|
|
|
|||
|
|
@ -428,4 +428,150 @@ packages/frontend/src/crm/companies/
|
|||
|
||||
---
|
||||
|
||||
## 2026-03-10 | Backend: Lexware Office Integration
|
||||
|
||||
### Ueberblick
|
||||
|
||||
Der CRM-Service ist jetzt mit Lexware Office (Buchhaltung/ERP) integriert. Drei Hauptfunktionen:
|
||||
|
||||
1. **Kontakt-Verknuepfung**: Lexware-Kontakte suchen und mit CRM Companies/Contacts verknuepfen oder importieren
|
||||
2. **Beleg-Anzeige**: Angebote, Auftragsbestaetigungen, Rechnungen und Gutschriften aus Lexware — anzeigbar am Unternehmen, Kontakt UND am Vorgang
|
||||
3. **ERP-Push**: CRM-Entitaeten mit Tag "ERP" werden automatisch nach Lexware synchronisiert
|
||||
|
||||
### Neue API-Endpoints: Lexware Kontakte
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| GET | `/crm/lexware/contacts/search?name=&email=` | Lexware-Kontakte suchen (Proxy zur Lexware API) |
|
||||
| POST | `/crm/lexware/contacts/link-company` | Lexware-Kontakt mit CRM Company verknuepfen |
|
||||
| POST | `/crm/lexware/contacts/link-contact` | Lexware-Kontakt mit CRM Contact verknuepfen |
|
||||
| DELETE | `/crm/lexware/contacts/unlink-company/:companyId` | Verknuepfung Company <-> Lexware loesen |
|
||||
| DELETE | `/crm/lexware/contacts/unlink-contact/:contactId` | Verknuepfung Contact <-> Lexware loesen |
|
||||
| POST | `/crm/lexware/contacts/import-company` | Neue CRM Company aus Lexware-Daten erstellen |
|
||||
| POST | `/crm/lexware/contacts/import-contact` | Neuen CRM Contact aus Lexware-Daten erstellen |
|
||||
| POST | `/crm/lexware/contacts/push/:entityType/:entityId` | CRM-Entitaet nach Lexware pushen (company/contact) |
|
||||
| POST | `/crm/lexware/contacts/sync/:entityType/:entityId` | Lexware-Daten in CRM aktualisieren |
|
||||
|
||||
### Neue API-Endpoints: Lexware Belege (Vouchers)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| GET | `/crm/lexware/vouchers/company/:companyId` | Belege fuer Unternehmen (gecacht) |
|
||||
| GET | `/crm/lexware/vouchers/contact/:contactId` | Belege fuer Kontakt (gecacht) |
|
||||
| GET | `/crm/lexware/vouchers/deal/:dealId` | Belege fuer Vorgang (via DealVoucher) |
|
||||
| POST | `/crm/lexware/vouchers/deal/:dealId/link` | Beleg mit Vorgang verknuepfen |
|
||||
| DELETE | `/crm/lexware/vouchers/deal/:dealId/unlink/:voucherId` | Beleg-Verknuepfung loesen |
|
||||
| POST | `/crm/lexware/vouchers/refresh/company/:companyId` | Beleg-Cache manuell aktualisieren |
|
||||
| POST | `/crm/lexware/vouchers/refresh/contact/:contactId` | Beleg-Cache manuell aktualisieren |
|
||||
|
||||
### Beleg-Filter Query-Parameter
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
|-----------|-----|-------------|
|
||||
| `voucherType` | string | `QUOTATION`, `ORDER_CONFIRMATION`, `INVOICE`, `CREDIT_NOTE` |
|
||||
| `voucherStatus` | string | Freitext-Filter nach Beleg-Status |
|
||||
| `page` | number | Seite (default: 1) |
|
||||
| `pageSize` | number | Eintraege pro Seite (default: 20) |
|
||||
|
||||
### LexwareVoucher-Objekt
|
||||
|
||||
```typescript
|
||||
interface LexwareVoucher {
|
||||
id: string; // CRM-interne UUID
|
||||
voucherType: 'QUOTATION' | 'ORDER_CONFIRMATION' | 'INVOICE' | 'CREDIT_NOTE';
|
||||
voucherNumber?: string; // z.B. "RE-2025-001"
|
||||
voucherDate?: string; // ISO DateTime
|
||||
voucherStatus?: string; // z.B. "open", "paid", "overdue"
|
||||
totalGrossAmount?: string; // Decimal als String, z.B. "1190.00"
|
||||
totalNetAmount?: string;
|
||||
totalTaxAmount?: string;
|
||||
currency: string; // Default "EUR"
|
||||
title?: string;
|
||||
lineItemsCount?: number;
|
||||
lineItemsJson?: string; // JSON-String mit Positionen
|
||||
lexwareDeepLink?: string; // Link direkt zu Lexware Office
|
||||
fetchedAt: string; // Wann zuletzt aus Lexware geholt
|
||||
}
|
||||
```
|
||||
|
||||
### Aenderungen an bestehenden Responses
|
||||
|
||||
**Company-Objekt** hat jetzt zusaetzliche Felder:
|
||||
```json
|
||||
{
|
||||
"lexwareContactId": "abc-123", // null wenn nicht verknuepft
|
||||
"lexwareContactVersion": 3, // Optimistic Locking
|
||||
"lexwareSyncedAt": "2026-03-10...", // Letzter Sync
|
||||
"_count": { "contacts": 5, "deals": 2, "lexwareVouchers": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
**Contact-Objekt**: Identische neue Felder wie Company.
|
||||
|
||||
**Deal-Detail** liefert jetzt zusaetzlich `dealVouchers[]`:
|
||||
```json
|
||||
{
|
||||
"dealVouchers": [
|
||||
{
|
||||
"id": "...",
|
||||
"linkedAt": "2026-03-10...",
|
||||
"voucher": {
|
||||
"id": "...",
|
||||
"voucherType": "INVOICE",
|
||||
"voucherNumber": "RE-2025-001",
|
||||
"voucherDate": "2025-12-15...",
|
||||
"voucherStatus": "paid",
|
||||
"totalGrossAmount": "1190.00",
|
||||
"currency": "EUR",
|
||||
"title": "Lizenzgebuehr Q4",
|
||||
"lexwareDeepLink": "https://app.lexware.de/permalink/..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Vorschlaege fuer das Frontend
|
||||
|
||||
1. **Company/Contact-Detail**: Tab oder Sektion "Lexware" mit:
|
||||
- Status-Badge: "Verknuepft" (gruen) / "Nicht verknuepft" (grau)
|
||||
- Button "Lexware-Kontakt suchen & verknuepfen" → Modal mit Suchfeld
|
||||
- Button "Verknuepfung loesen"
|
||||
- Button "Belege aktualisieren" (Refresh-Icon)
|
||||
- Beleg-Tabelle: Typ, Nummer, Datum, Status, Brutto-Betrag, Link zu Lexware
|
||||
|
||||
2. **Deal-Detail**: Sektion "Belege" mit:
|
||||
- Verknuepfte Belege als Tabelle (aus `dealVouchers`)
|
||||
- Button "Beleg verknuepfen" → Dropdown/Modal mit verfuegbaren Belegen des Unternehmens/Kontakts
|
||||
- Jeder Beleg hat einen externen Link zu Lexware Office
|
||||
|
||||
3. **Tags-Integration**: "ERP"-Tag in Company/Contact-Formularen hervorheben (z.B. besondere Farbe), da es den automatischen Push nach Lexware aktiviert
|
||||
|
||||
4. **VoucherType Labels** fuer die UI:
|
||||
- `QUOTATION` → "Angebot"
|
||||
- `ORDER_CONFIRMATION` → "Auftragsbestaetigung"
|
||||
- `INVOICE` → "Rechnung"
|
||||
- `CREDIT_NOTE` → "Gutschrift"
|
||||
|
||||
### Cron-Jobs (automatisch im Hintergrund)
|
||||
|
||||
- **Beleg-Sync**: Alle 4 Stunden werden Belege fuer alle verknuepften Entitaeten aus Lexware geholt
|
||||
- **ERP-Push**: Alle 30 Minuten werden Companies/Contacts mit "ERP"-Tag nach Lexware gepusht
|
||||
|
||||
### Health Check
|
||||
|
||||
`GET /health` zeigt jetzt `"lexware": "up"|"down"|"unconfigured"`:
|
||||
- `up`: Lexware API erreichbar
|
||||
- `down`: API-Key konfiguriert aber API nicht erreichbar
|
||||
- `unconfigured`: Kein API-Key gesetzt (kein Fehler, Modul einfach deaktiviert)
|
||||
|
||||
### Deployment-Hinweise
|
||||
|
||||
- Neue Env-Variable auf Server: `LEXWARE_API_KEY` (in `.env`)
|
||||
- DB-Migration noetig: `migration.sql` in `prisma/migrations/20260310_add_lexware_integration/`
|
||||
- Neue Tabellen: `lexware_vouchers`, `deal_vouchers`
|
||||
- Neue Felder: `lexware_contact_id`, `lexware_contact_version`, `lexware_synced_at` auf `companies` und `contacts`
|
||||
|
||||
---
|
||||
|
||||
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
||||
|
|
|
|||
|
|
@ -10,47 +10,68 @@ Der CRM-Service als eigenstaendiges NestJS-Package unter `packages/crm-service/`
|
|||
|
||||
```
|
||||
packages/crm-service/
|
||||
package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis)
|
||||
package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis, @nestjs/axios, @nestjs/schedule)
|
||||
tsconfig.json — Strict TypeScript
|
||||
nest-cli.json — NestJS CLI Config
|
||||
Dockerfile — Multi-Stage (base, deps, development, build, production)
|
||||
.dockerignore — Excludes
|
||||
prisma/
|
||||
crm.schema.prisma — Eigenes Schema (app_crm) mit eigenem Client-Output
|
||||
migrations/ — SQL-Migrationen
|
||||
src/
|
||||
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
||||
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter
|
||||
config/ — Umgebungsvariablen-Validierung
|
||||
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule
|
||||
config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*)
|
||||
prisma/ — CrmPrismaService (eigener Client)
|
||||
redis/ — RedisService (Token-Blocklist, Cache)
|
||||
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
||||
companies/ — CRUD: Unternehmen (uebergeordnete Entity)
|
||||
contacts/ — CRUD: Kontakte (PERSON, ORGANIZATION) mit Company-Verknuepfung
|
||||
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push bei Update)
|
||||
contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update)
|
||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK)
|
||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company-Zuordnung
|
||||
deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers
|
||||
health/ — Health-Check (DB, Redis, Lexware)
|
||||
lexware/ — Lexware Office Integration (NEU)
|
||||
lexware.module.ts — Feature Module (HttpModule + ScheduleModule)
|
||||
lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s)
|
||||
lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync
|
||||
lexware-vouchers.service.ts — Beleg-Abruf, Cache, Deal-Verknuepfung
|
||||
lexware-sync.service.ts — Cron-Jobs (Beleg-Sync 4h, ERP-Push 30min)
|
||||
lexware-contacts.controller.ts — REST Endpoints Kontakt-Operationen
|
||||
lexware-vouchers.controller.ts — REST Endpoints Beleg-Operationen
|
||||
dto/ — Validierungs-DTOs
|
||||
interfaces/ — TypeScript Interfaces fuer Lexware API
|
||||
utils/
|
||||
rate-limiter.ts — Token Bucket (max 2, 2/s Refill)
|
||||
lexware-mapper.ts — Bidirektionales Mapping CRM <-> Lexware
|
||||
```
|
||||
|
||||
### Datenbank-Modelle (app_crm Schema)
|
||||
|
||||
- **Company** — Unternehmen mit Branche, Adresse, Tags, Audit-Trail. Eltern-Entity fuer Contacts und Deals.
|
||||
- **Contact** — Kontakte (Person/Organisation) mit optionaler Company-Zuordnung (companyId, position)
|
||||
- **Company** — Unternehmen mit Lexware-Verknuepfung (lexwareContactId, lexwareContactVersion, lexwareSyncedAt)
|
||||
- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung
|
||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten
|
||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||
- **PipelineStage** — Stufen innerhalb einer Pipeline (Name, Farbe, Reihenfolge editierbar)
|
||||
- **Deal** — Vorgaenge mit Wert, Status, Pipeline/Stage/Contact/Company-Zuordnung
|
||||
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||
- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen
|
||||
- **LexwareVoucher** (NEU) — Gecachte Belege aus Lexware Office (Angebote, Auftraege, Rechnungen, Gutschriften)
|
||||
- **DealVoucher** (NEU) — Join-Table Deal <-> Beleg (m:n mit Audit-Trail)
|
||||
|
||||
### Entity-Beziehungen
|
||||
|
||||
```
|
||||
Company (1) --< (n) Contact — companyId (optional, SetNull bei Loeschung)
|
||||
Company (1) --< (n) Deal — companyId (optional, SetNull bei Loeschung)
|
||||
Contact (1) --< (n) Activity — contactId (Cascade bei Loeschung)
|
||||
Contact (1) --< (n) Deal — contactId (optional, SetNull bei Loeschung)
|
||||
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade bei Loeschung)
|
||||
Pipeline (1) --< (n) Deal — pipelineId (Cascade bei Loeschung)
|
||||
PipelineStage (1) --< (n) Deal — stageId
|
||||
Company (1) --< (n) Contact — companyId (optional, SetNull)
|
||||
Company (1) --< (n) Deal — companyId (optional, SetNull)
|
||||
Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull)
|
||||
Contact (1) --< (n) Activity — contactId (Cascade)
|
||||
Contact (1) --< (n) Deal — contactId (optional, SetNull)
|
||||
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
|
||||
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade)
|
||||
Pipeline (1) --< (n) Deal — pipelineId (Cascade)
|
||||
PipelineStage (1) --< (n) Deal — stageId
|
||||
Deal (1) --< (n) DealVoucher — dealId (Cascade)
|
||||
LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade)
|
||||
```
|
||||
|
||||
### API-Endpunkte
|
||||
|
|
@ -66,73 +87,68 @@ PipelineStage (1) --< (n) Deal — stageId
|
|||
| GET/POST | /api/v1/crm/pipelines | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
|
||||
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
||||
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten (Name, Farbe, Reihenfolge) |
|
||||
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
|
||||
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
||||
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
||||
| GET | /health | Health-Check (public) |
|
||||
| GET | /health | Health-Check (DB, Redis, Lexware) |
|
||||
| **Lexware Kontakte** | | |
|
||||
| GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen |
|
||||
| POST | /api/v1/crm/lexware/contacts/link-company | Company verknuepfen |
|
||||
| POST | /api/v1/crm/lexware/contacts/link-contact | Contact verknuepfen |
|
||||
| DELETE | /api/v1/crm/lexware/contacts/unlink-company/:id | Verknuepfung loesen |
|
||||
| DELETE | /api/v1/crm/lexware/contacts/unlink-contact/:id | Verknuepfung loesen |
|
||||
| POST | /api/v1/crm/lexware/contacts/import-company | Company aus Lexware importieren |
|
||||
| POST | /api/v1/crm/lexware/contacts/import-contact | Contact aus Lexware importieren |
|
||||
| POST | /api/v1/crm/lexware/contacts/push/:type/:id | CRM -> Lexware pushen |
|
||||
| POST | /api/v1/crm/lexware/contacts/sync/:type/:id | Lexware -> CRM synchronisieren |
|
||||
| **Lexware Belege** | | |
|
||||
| GET | /api/v1/crm/lexware/vouchers/company/:id | Belege fuer Unternehmen |
|
||||
| GET | /api/v1/crm/lexware/vouchers/contact/:id | Belege fuer Kontakt |
|
||||
| GET | /api/v1/crm/lexware/vouchers/deal/:id | Belege fuer Vorgang |
|
||||
| POST | /api/v1/crm/lexware/vouchers/deal/:id/link | Beleg mit Vorgang verknuepfen |
|
||||
| DELETE | /api/v1/crm/lexware/vouchers/deal/:id/unlink/:vid | Verknuepfung loesen |
|
||||
| POST | /api/v1/crm/lexware/vouchers/refresh/company/:id | Cache aktualisieren |
|
||||
| POST | /api/v1/crm/lexware/vouchers/refresh/contact/:id | Cache aktualisieren |
|
||||
|
||||
### Lexware Office Integration — Details
|
||||
|
||||
- **Rate Limiter**: In-Memory Token Bucket, 2 Requests/Sekunde (Lexware API Limit)
|
||||
- **Beleg-Caching**: PostgreSQL-Tabelle `lexware_vouchers`, alle 4h Cron-Refresh + manueller Refresh
|
||||
- **ERP-Push**: Companies/Contacts mit Tag "ERP" werden automatisch (30min Cron) + bei Update nach Lexware gepusht
|
||||
- **Distributed Locks**: Redis SET NX EX verhindert Doppelausfuehrung von Cron-Jobs
|
||||
- **Optimistic Locking**: lexwareContactVersion fuer sichere Updates
|
||||
- **Graceful Degradation**: Ohne LEXWARE_API_KEY → Modul deaktiviert, Health = "unconfigured"
|
||||
|
||||
### Docker-Integration
|
||||
|
||||
- `docker-compose.crm.yml` im Projekt-Root
|
||||
- Port: 3100
|
||||
- Netzwerke: insight-web, insight-db, insight-cache
|
||||
- Traefik HTTP-Route: `Host(172.20.10.59) && PathPrefix(/api/v1/crm)` mit Priority 100
|
||||
- Traefik HTTPS-Route: `crm-secure` mit `entrypoints=websecure`, `tls=true`, Priority 100
|
||||
- JWT Public Key als Read-Only Volume (.keys/jwt-public.pem)
|
||||
- Direkte PostgreSQL-Verbindung (PgBouncer unterstuetzt kein search_path fuer Schema-Auswahl)
|
||||
- Neue Env-Variablen: `LEXWARE_API_KEY`, `LEXWARE_API_URL`
|
||||
- Traefik HTTP + HTTPS Routing: `/api/v1/crm/*`
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- JWT RS256 Validierung mit shared Public Key
|
||||
- Token-Revocation via Redis (blocked:{jti})
|
||||
- Token-Revocation via Redis
|
||||
- Multi-Tenancy: Alle Queries filtern nach tenantId
|
||||
- TenantGuard sichert mandantenbezogenen Zugriff
|
||||
- Globaler ValidationPipe (whitelist + forbidNonWhitelisted)
|
||||
- Strict TypeScript, kein `any`
|
||||
- 401 bei fehlendem/ungueltigem Token
|
||||
|
||||
### Deployment-Status
|
||||
|
||||
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
|
||||
|
||||
- Container: insight-crm (Development-Mode)
|
||||
- Prisma Migrationen angewendet:
|
||||
- `20260310163211_init` — Initiales Schema (Contact, Activity, Pipeline, PipelineStage, Deal)
|
||||
- `20260310183117_add_companies` — Company-Entity, Contact.companyId/position, Deal.companyId
|
||||
- Alle API-Endpunkte getestet und funktionsfaehig
|
||||
- Traefik-Routing aktiv (HTTP + HTTPS): http(s)://172.20.10.59/api/v1/crm/*
|
||||
- Swagger-Docs: http://172.20.10.59/api/v1/crm/docs/
|
||||
|
||||
### Getestete Endpunkte
|
||||
|
||||
| Test | Ergebnis |
|
||||
|------|----------|
|
||||
| POST /companies (Erstellen mit allen Feldern) | 200 OK, UUID + _count korrekt |
|
||||
| GET /companies (Liste) | 200 OK, pagination + _count korrekt |
|
||||
| GET /companies/:id (Detail) | 200 OK, contacts[] + deals[] + _count |
|
||||
| PATCH /companies/:id (Update) | 200 OK, updatedBy + tags korrekt |
|
||||
| GET /companies?search=Xinion | 200 OK, Suche funktioniert |
|
||||
| POST /contacts (mit companyId + position) | 201 Created, Company-Verknuepfung korrekt |
|
||||
| GET /contacts/:id (mit Company) | 200 OK, company-Objekt enthalten |
|
||||
| POST /activities (Notiz) | 201 Created, contactId verknuepft |
|
||||
| POST /pipelines (mit 4 Stages) | 201 Created, Stages korrekt |
|
||||
| PATCH /pipelines/:id/stages/:stageId | 200 OK, Stage-Update korrekt |
|
||||
| POST /deals (mit companyId + contactId) | 200 OK, Company + Contact verknuepft |
|
||||
| GET /deals/:id (mit Company) | 200 OK, company + pipeline.stages enthalten |
|
||||
| GET /deals?companyId=... | 200 OK, Filter nach Company funktioniert |
|
||||
| PATCH /deals/:id (WON) | 200 OK, closedAt automatisch gesetzt |
|
||||
| GET /contacts ohne Token | 401 Unauthorized |
|
||||
| Validierung (falsche Felder) | 400 Bad Request, Details korrekt |
|
||||
|
||||
### Bekannte Einschraenkungen
|
||||
|
||||
- PgBouncer kann nicht genutzt werden (search_path nicht kompatibel mit transaction pooling)
|
||||
- Prisma Migrationen:
|
||||
- `20260310163211_init` — Initiales Schema
|
||||
- `20260310183117_add_companies` — Company-Entity
|
||||
- `20260310_add_lexware_integration` — Lexware Office Integration (AUSSTEHEND)
|
||||
|
||||
### Naechste Schritte
|
||||
|
||||
1. Frontend: Company-Modul (Seiten, Formulare, Sidebar-Link)
|
||||
2. Frontend: Contact/Deal-Formulare um Company-Selektor erweitern
|
||||
3. Activity-Liste komplett laden (UI-Button "Alle anzeigen")
|
||||
4. Kanban-Board fuer Vorgaenge (Drag & Drop Stage-Wechsel)
|
||||
5. E2E-Tests schreiben
|
||||
6. Production-Build testen (multi-stage Dockerfile)
|
||||
1. Migration `20260310_add_lexware_integration` auf Server anwenden
|
||||
2. `LEXWARE_API_KEY` in `.env` auf Server setzen
|
||||
3. Container neu bauen und deployen
|
||||
4. Lexware-Endpunkte auf Server testen
|
||||
5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten
|
||||
6. Activity-Liste komplett laden (UI-Button "Alle anzeigen")
|
||||
7. Kanban-Board fuer Vorgaenge
|
||||
|
|
|
|||
178
packages/crm-service/package-lock.json
generated
178
packages/crm-service/package-lock.json
generated
|
|
@ -9,13 +9,16 @@
|
|||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@prisma/client": "^6.4.0",
|
||||
"axios": "^1.13.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
|
@ -244,6 +247,7 @@
|
|||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -1726,6 +1730,17 @@
|
|||
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nestjs/axios": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz",
|
||||
"integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"axios": "^1.3.1",
|
||||
"rxjs": "^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cli": {
|
||||
"version": "10.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
||||
|
|
@ -2001,6 +2016,33 @@
|
|||
"@nestjs/core": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "3.2.1",
|
||||
"uuid": "11.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule/node_modules/uuid": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
|
||||
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||
|
|
@ -2504,6 +2546,12 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
|
|
@ -3257,6 +3305,24 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
|
|
@ -4063,6 +4129,18 @@
|
|||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
|
|
@ -4257,6 +4335,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
|
||||
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.4.0",
|
||||
"luxon": "~3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -4369,6 +4457,15 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
|
|
@ -4609,6 +4706,21 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
|
@ -5353,6 +5465,26 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
|
|
@ -5430,6 +5562,22 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -5792,6 +5940,21 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
|
@ -7314,6 +7477,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.8",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||
|
|
@ -8277,6 +8449,12 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -25,13 +25,16 @@
|
|||
"prisma:studio": "prisma studio --schema=prisma/crm.schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@prisma/client": "^6.4.0",
|
||||
"axios": "^1.13.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
|
@ -69,13 +72,19 @@
|
|||
"typescript": "^5.6.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ model Company {
|
|||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Lexware Office Integration
|
||||
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
||||
lexwareContactVersion Int? @map("lexware_contact_version")
|
||||
lexwareSyncedAt DateTime? @map("lexware_synced_at")
|
||||
|
||||
// Audit-Trail
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
updatedBy String? @map("updated_by") @db.Uuid
|
||||
|
|
@ -53,9 +58,11 @@ model Company {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
contacts Contact[]
|
||||
deals Deal[]
|
||||
contacts Contact[]
|
||||
deals Deal[]
|
||||
lexwareVouchers LexwareVoucher[]
|
||||
|
||||
@@unique([tenantId, lexwareContactId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, name])
|
||||
@@index([tenantId, industry])
|
||||
|
|
@ -100,6 +107,11 @@ model Contact {
|
|||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Lexware Office Integration
|
||||
lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36)
|
||||
lexwareContactVersion Int? @map("lexware_contact_version")
|
||||
lexwareSyncedAt DateTime? @map("lexware_synced_at")
|
||||
|
||||
// Audit-Trail (User-IDs aus platform_core)
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
updatedBy String? @map("updated_by") @db.Uuid
|
||||
|
|
@ -108,10 +120,12 @@ model Contact {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
activities Activity[]
|
||||
deals Deal[]
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
activities Activity[]
|
||||
deals Deal[]
|
||||
lexwareVouchers LexwareVoucher[]
|
||||
|
||||
@@unique([tenantId, lexwareContactId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, email])
|
||||
@@index([tenantId, companyId])
|
||||
|
|
@ -250,10 +264,11 @@ model Deal {
|
|||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
stage PipelineStage @relation(fields: [stageId], references: [id])
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
stage PipelineStage @relation(fields: [stageId], references: [id])
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
dealVouchers DealVoucher[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, pipelineId])
|
||||
|
|
@ -272,3 +287,86 @@ enum DealStatus {
|
|||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware Office Integration - Voucher Types
|
||||
// --------------------------------------------------------
|
||||
enum VoucherType {
|
||||
QUOTATION
|
||||
ORDER_CONFIRMATION
|
||||
INVOICE
|
||||
CREDIT_NOTE
|
||||
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// LexwareVoucher - Gecachte Belege aus Lexware Office
|
||||
// --------------------------------------------------------
|
||||
model LexwareVoucher {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
lexwareVoucherId String @map("lexware_voucher_id") @db.VarChar(36)
|
||||
voucherType VoucherType @map("voucher_type")
|
||||
|
||||
voucherNumber String? @map("voucher_number") @db.VarChar(100)
|
||||
voucherDate DateTime? @map("voucher_date")
|
||||
voucherStatus String? @map("voucher_status") @db.VarChar(50)
|
||||
|
||||
totalGrossAmount Decimal? @map("total_gross_amount") @db.Decimal(15, 2)
|
||||
totalNetAmount Decimal? @map("total_net_amount") @db.Decimal(15, 2)
|
||||
totalTaxAmount Decimal? @map("total_tax_amount") @db.Decimal(15, 2)
|
||||
currency String @default("EUR") @db.VarChar(3)
|
||||
|
||||
title String? @db.VarChar(500)
|
||||
lineItemsCount Int? @map("line_items_count")
|
||||
lineItemsJson String? @map("line_items_json") @db.Text
|
||||
|
||||
// Verknuepfung zu Lexware-Kontakt und CRM-Entitaeten
|
||||
lexwareContactId String @map("lexware_contact_id") @db.VarChar(36)
|
||||
companyId String? @map("company_id") @db.Uuid
|
||||
contactId String? @map("contact_id") @db.Uuid
|
||||
|
||||
lexwareDeepLink String? @map("lexware_deep_link") @db.VarChar(500)
|
||||
|
||||
fetchedAt DateTime @default(now()) @map("fetched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
deals DealVoucher[]
|
||||
|
||||
@@unique([tenantId, lexwareVoucherId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, companyId])
|
||||
@@index([tenantId, contactId])
|
||||
@@index([tenantId, lexwareContactId])
|
||||
@@index([tenantId, voucherType])
|
||||
@@map("lexware_vouchers")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DealVoucher - Verknuepfung Deal <-> Lexware-Beleg (m:n)
|
||||
// --------------------------------------------------------
|
||||
model DealVoucher {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
dealId String @map("deal_id") @db.Uuid
|
||||
voucherId String @map("voucher_id") @db.Uuid
|
||||
linkedBy String @map("linked_by") @db.Uuid
|
||||
linkedAt DateTime @default(now()) @map("linked_at")
|
||||
|
||||
// Relationen
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
voucher LexwareVoucher @relation(fields: [voucherId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([dealId, voucherId])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, dealId])
|
||||
@@index([tenantId, voucherId])
|
||||
@@map("deal_vouchers")
|
||||
@@schema("app_crm")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
-- ============================================================
|
||||
-- Lexware Office Integration - Schema Migration
|
||||
-- ============================================================
|
||||
-- Neue Felder auf Company, Contact
|
||||
-- Neue Tabellen: lexware_vouchers, deal_vouchers
|
||||
-- Neuer Enum: VoucherType
|
||||
-- ============================================================
|
||||
|
||||
-- VoucherType Enum
|
||||
CREATE TYPE "app_crm"."VoucherType" AS ENUM ('QUOTATION', 'ORDER_CONFIRMATION', 'INVOICE', 'CREDIT_NOTE');
|
||||
|
||||
-- Company: Lexware-Felder hinzufuegen
|
||||
ALTER TABLE "app_crm"."companies"
|
||||
ADD COLUMN "lexware_contact_id" VARCHAR(36),
|
||||
ADD COLUMN "lexware_contact_version" INTEGER,
|
||||
ADD COLUMN "lexware_synced_at" TIMESTAMP(3);
|
||||
|
||||
-- Contact: Lexware-Felder hinzufuegen
|
||||
ALTER TABLE "app_crm"."contacts"
|
||||
ADD COLUMN "lexware_contact_id" VARCHAR(36),
|
||||
ADD COLUMN "lexware_contact_version" INTEGER,
|
||||
ADD COLUMN "lexware_synced_at" TIMESTAMP(3);
|
||||
|
||||
-- Unique Constraints (tenantId + lexwareContactId)
|
||||
CREATE UNIQUE INDEX "companies_tenant_id_lexware_contact_id_key"
|
||||
ON "app_crm"."companies"("tenant_id", "lexware_contact_id");
|
||||
|
||||
CREATE UNIQUE INDEX "contacts_tenant_id_lexware_contact_id_key"
|
||||
ON "app_crm"."contacts"("tenant_id", "lexware_contact_id");
|
||||
|
||||
-- LexwareVoucher Tabelle
|
||||
CREATE TABLE "app_crm"."lexware_vouchers" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"lexware_voucher_id" VARCHAR(36) NOT NULL,
|
||||
"voucher_type" "app_crm"."VoucherType" NOT NULL,
|
||||
"voucher_number" VARCHAR(100),
|
||||
"voucher_date" TIMESTAMP(3),
|
||||
"voucher_status" VARCHAR(50),
|
||||
"total_gross_amount" DECIMAL(15,2),
|
||||
"total_net_amount" DECIMAL(15,2),
|
||||
"total_tax_amount" DECIMAL(15,2),
|
||||
"currency" VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
"title" VARCHAR(500),
|
||||
"line_items_count" INTEGER,
|
||||
"line_items_json" TEXT,
|
||||
"lexware_contact_id" VARCHAR(36) NOT NULL,
|
||||
"company_id" UUID,
|
||||
"contact_id" UUID,
|
||||
"lexware_deep_link" VARCHAR(500),
|
||||
"fetched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "lexware_vouchers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- LexwareVoucher Indizes
|
||||
CREATE UNIQUE INDEX "lexware_vouchers_tenant_id_lexware_voucher_id_key"
|
||||
ON "app_crm"."lexware_vouchers"("tenant_id", "lexware_voucher_id");
|
||||
|
||||
CREATE INDEX "lexware_vouchers_tenant_id_idx"
|
||||
ON "app_crm"."lexware_vouchers"("tenant_id");
|
||||
|
||||
CREATE INDEX "lexware_vouchers_tenant_id_company_id_idx"
|
||||
ON "app_crm"."lexware_vouchers"("tenant_id", "company_id");
|
||||
|
||||
CREATE INDEX "lexware_vouchers_tenant_id_contact_id_idx"
|
||||
ON "app_crm"."lexware_vouchers"("tenant_id", "contact_id");
|
||||
|
||||
CREATE INDEX "lexware_vouchers_tenant_id_lexware_contact_id_idx"
|
||||
ON "app_crm"."lexware_vouchers"("tenant_id", "lexware_contact_id");
|
||||
|
||||
CREATE INDEX "lexware_vouchers_tenant_id_voucher_type_idx"
|
||||
ON "app_crm"."lexware_vouchers"("tenant_id", "voucher_type");
|
||||
|
||||
-- LexwareVoucher Foreign Keys
|
||||
ALTER TABLE "app_crm"."lexware_vouchers"
|
||||
ADD CONSTRAINT "lexware_vouchers_company_id_fkey"
|
||||
FOREIGN KEY ("company_id") REFERENCES "app_crm"."companies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "app_crm"."lexware_vouchers"
|
||||
ADD CONSTRAINT "lexware_vouchers_contact_id_fkey"
|
||||
FOREIGN KEY ("contact_id") REFERENCES "app_crm"."contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- DealVoucher Join-Tabelle
|
||||
CREATE TABLE "app_crm"."deal_vouchers" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"deal_id" UUID NOT NULL,
|
||||
"voucher_id" UUID NOT NULL,
|
||||
"linked_by" UUID NOT NULL,
|
||||
"linked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "deal_vouchers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- DealVoucher Indizes
|
||||
CREATE UNIQUE INDEX "deal_vouchers_deal_id_voucher_id_key"
|
||||
ON "app_crm"."deal_vouchers"("deal_id", "voucher_id");
|
||||
|
||||
CREATE INDEX "deal_vouchers_tenant_id_idx"
|
||||
ON "app_crm"."deal_vouchers"("tenant_id");
|
||||
|
||||
CREATE INDEX "deal_vouchers_tenant_id_deal_id_idx"
|
||||
ON "app_crm"."deal_vouchers"("tenant_id", "deal_id");
|
||||
|
||||
CREATE INDEX "deal_vouchers_tenant_id_voucher_id_idx"
|
||||
ON "app_crm"."deal_vouchers"("tenant_id", "voucher_id");
|
||||
|
||||
-- DealVoucher Foreign Keys
|
||||
ALTER TABLE "app_crm"."deal_vouchers"
|
||||
ADD CONSTRAINT "deal_vouchers_deal_id_fkey"
|
||||
FOREIGN KEY ("deal_id") REFERENCES "app_crm"."deals"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "app_crm"."deal_vouchers"
|
||||
ADD CONSTRAINT "deal_vouchers_voucher_id_fkey"
|
||||
FOREIGN KEY ("voucher_id") REFERENCES "app_crm"."lexware_vouchers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||
import { validate } from './config/env.validation';
|
||||
import { CrmPrismaModule } from './prisma/crm-prisma.module';
|
||||
|
|
@ -13,6 +14,7 @@ import { ActivitiesModule } from './activities/activities.module';
|
|||
import { PipelinesModule } from './pipelines/pipelines.module';
|
||||
import { DealsModule } from './deals/deals.module';
|
||||
import { CompaniesModule } from './companies/companies.module';
|
||||
import { LexwareModule } from './lexware/lexware.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -20,6 +22,7 @@ import { CompaniesModule } from './companies/companies.module';
|
|||
isGlobal: true,
|
||||
validate,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
CrmPrismaModule,
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
|
|
@ -29,6 +32,7 @@ import { CompaniesModule } from './companies/companies.module';
|
|||
PipelinesModule,
|
||||
DealsModule,
|
||||
CompaniesModule,
|
||||
LexwareModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
|
||||
@Module({
|
||||
imports: [CrmPrismaModule],
|
||||
imports: [CrmPrismaModule, LexwareModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService],
|
||||
exports: [CompaniesService],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
private readonly logger = new Logger(CompaniesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareContacts: LexwareContactsService,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
||||
return this.prisma.company.create({
|
||||
|
|
@ -106,7 +112,9 @@ export class CompaniesService {
|
|||
stage: { select: { id: true, name: true, color: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
_count: {
|
||||
select: { contacts: true, deals: true, lexwareVouchers: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -125,7 +133,7 @@ export class CompaniesService {
|
|||
) {
|
||||
await this.findOne(tenantId, id);
|
||||
|
||||
return this.prisma.company.update({
|
||||
const updated = await this.prisma.company.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
|
|
@ -135,6 +143,19 @@ export class CompaniesService {
|
|||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
||||
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
||||
this.lexwareContacts
|
||||
.pushCompanyToLexware(tenantId, id)
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(
|
||||
`ERP-Push nach Update fehlgeschlagen fuer Company "${updated.name}": ${err.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ export class EnvironmentVariables {
|
|||
@IsString()
|
||||
@IsOptional()
|
||||
CORS_ORIGINS?: string;
|
||||
|
||||
// Lexware Office Integration (optional)
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
LEXWARE_API_KEY?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
LEXWARE_API_URL?: string;
|
||||
}
|
||||
|
||||
export function validate(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContactsController } from './contacts.controller';
|
||||
import { ContactsService } from './contacts.service';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
|
||||
@Module({
|
||||
imports: [LexwareModule],
|
||||
controllers: [ContactsController],
|
||||
providers: [ContactsService],
|
||||
exports: [ContactsService],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { CreateContactDto } from './dto/create-contact.dto';
|
||||
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
|
||||
@Injectable()
|
||||
export class ContactsService {
|
||||
constructor(private readonly prisma: CrmPrismaService) {}
|
||||
private readonly logger = new Logger(ContactsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareContacts: LexwareContactsService,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||
return this.prisma.contact.create({
|
||||
|
|
@ -107,13 +113,26 @@ export class ContactsService {
|
|||
) {
|
||||
await this.findOne(tenantId, id);
|
||||
|
||||
return this.prisma.contact.update({
|
||||
const updated = await this.prisma.contact.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push
|
||||
if (updated.lexwareContactId && updated.tags.includes('ERP')) {
|
||||
this.lexwareContacts
|
||||
.pushContactToLexware(tenantId, id)
|
||||
.catch((err: Error) =>
|
||||
this.logger.warn(
|
||||
`ERP-Push nach Update fehlgeschlagen fuer Contact "${updated.firstName} ${updated.lastName}": ${err.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,24 @@ export class DealsService {
|
|||
stage: true,
|
||||
contact: true,
|
||||
company: true,
|
||||
dealVouchers: {
|
||||
include: {
|
||||
voucher: {
|
||||
select: {
|
||||
id: true,
|
||||
voucherType: true,
|
||||
voucherNumber: true,
|
||||
voucherDate: true,
|
||||
voucherStatus: true,
|
||||
totalGrossAmount: true,
|
||||
currency: true,
|
||||
title: true,
|
||||
lexwareDeepLink: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { linkedAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Optional } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { LexwareClientService } from '../lexware/lexware-client.service';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'error';
|
||||
|
|
@ -12,6 +13,7 @@ interface HealthResponse {
|
|||
services: {
|
||||
database: 'up' | 'down';
|
||||
redis: 'up' | 'down';
|
||||
lexware: 'up' | 'down' | 'unconfigured';
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -21,35 +23,39 @@ export class HealthController {
|
|||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly redis: RedisService,
|
||||
@Optional() @Inject(LexwareClientService)
|
||||
private readonly lexwareClient?: LexwareClientService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
||||
async check(): Promise<HealthResponse> {
|
||||
const [dbStatus, redisStatus] = await Promise.allSettled([
|
||||
const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([
|
||||
this.checkDatabase(),
|
||||
this.checkRedis(),
|
||||
this.checkLexware(),
|
||||
]);
|
||||
|
||||
const allUp =
|
||||
dbStatus.status === 'fulfilled' &&
|
||||
dbStatus.value &&
|
||||
redisStatus.status === 'fulfilled' &&
|
||||
redisStatus.value;
|
||||
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
|
||||
const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value;
|
||||
const lexwareResult: 'up' | 'down' | 'unconfigured' =
|
||||
lexwareStatus.status === 'fulfilled'
|
||||
? lexwareStatus.value
|
||||
: 'down';
|
||||
|
||||
// Lexware "unconfigured" ist kein Fehler
|
||||
const allUp = dbOk && redisOk && lexwareResult !== 'down';
|
||||
|
||||
return {
|
||||
status: allUp ? 'ok' : 'error',
|
||||
service: 'crm-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.1.0',
|
||||
version: '0.2.0',
|
||||
services: {
|
||||
database:
|
||||
dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down',
|
||||
redis:
|
||||
redisStatus.status === 'fulfilled' && redisStatus.value
|
||||
? 'up'
|
||||
: 'down',
|
||||
database: dbOk ? 'up' : 'down',
|
||||
redis: redisOk ? 'up' : 'down',
|
||||
lexware: lexwareResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -71,4 +77,9 @@ export class HealthController {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkLexware(): Promise<'up' | 'down' | 'unconfigured'> {
|
||||
if (!this.lexwareClient) return 'unconfigured';
|
||||
return this.lexwareClient.isHealthy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { LexwareModule } from '../lexware/lexware.module';
|
||||
|
||||
@Module({
|
||||
imports: [LexwareModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class LinkDealVoucherDto {
|
||||
@ApiProperty({ description: 'CRM LexwareVoucher UUID (gecachter Beleg)' })
|
||||
@IsUUID()
|
||||
voucherId!: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class LinkLexwareToCompanyDto {
|
||||
@ApiProperty({ description: 'CRM Company UUID' })
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Lexware Office Contact ID' })
|
||||
@IsString()
|
||||
lexwareContactId!: string;
|
||||
}
|
||||
|
||||
export class LinkLexwareToContactDto {
|
||||
@ApiProperty({ description: 'CRM Contact UUID' })
|
||||
@IsUUID()
|
||||
contactId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Lexware Office Contact ID' })
|
||||
@IsString()
|
||||
lexwareContactId!: string;
|
||||
}
|
||||
|
||||
export class ImportLexwareContactDto {
|
||||
@ApiProperty({ description: 'Lexware Office Contact ID' })
|
||||
@IsString()
|
||||
lexwareContactId!: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsNumber, IsEnum, Min, Max } from 'class-validator';
|
||||
|
||||
export enum VoucherTypeFilter {
|
||||
QUOTATION = 'QUOTATION',
|
||||
ORDER_CONFIRMATION = 'ORDER_CONFIRMATION',
|
||||
INVOICE = 'INVOICE',
|
||||
CREDIT_NOTE = 'CREDIT_NOTE',
|
||||
}
|
||||
|
||||
export class QueryLexwareVouchersDto {
|
||||
@ApiPropertyOptional({ enum: VoucherTypeFilter })
|
||||
@IsOptional()
|
||||
@IsEnum(VoucherTypeFilter)
|
||||
voucherType?: VoucherTypeFilter;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Beleg-Status (draft, open, paidoff, voided)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
voucherStatus?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 25, maximum: 100 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SearchLexwareContactsDto {
|
||||
@ApiPropertyOptional({ description: 'Suche nach Name (min 3 Zeichen)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Suche nach E-Mail (min 3 Zeichen)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exakte Kunden-/Lieferantennummer' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
number?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Nur Kunden anzeigen',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
customer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Nur Lieferanten anzeigen' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
vendor?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Seite (0-basiert)', default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Seitengroesse', default: 25 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
size?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
// ============================================================
|
||||
// Lexware Office API - TypeScript Interfaces
|
||||
// ============================================================
|
||||
// Basiert auf: https://developers.lexware.io/docs/
|
||||
// ============================================================
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Contact Interfaces
|
||||
// --------------------------------------------------------
|
||||
|
||||
export interface LexwareContactPerson {
|
||||
salutation?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export interface LexwareContactCompany {
|
||||
name?: string;
|
||||
taxNumber?: string;
|
||||
vatRegistrationId?: string;
|
||||
allowTaxFreeInvoices?: boolean;
|
||||
contactPersons?: LexwareContactPersonEntry[];
|
||||
}
|
||||
|
||||
export interface LexwareContactPersonEntry {
|
||||
salutation?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
primary?: boolean;
|
||||
emailAddress?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
export interface LexwareAddress {
|
||||
supplement?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export interface LexwareEmailAddresses {
|
||||
business?: string[];
|
||||
office?: string[];
|
||||
private?: string[];
|
||||
other?: string[];
|
||||
}
|
||||
|
||||
export interface LexwarePhoneNumbers {
|
||||
business?: string[];
|
||||
office?: string[];
|
||||
mobile?: string[];
|
||||
private?: string[];
|
||||
fax?: string[];
|
||||
other?: string[];
|
||||
}
|
||||
|
||||
export interface LexwareContactRoles {
|
||||
customer?: { number?: number };
|
||||
vendor?: { number?: number };
|
||||
}
|
||||
|
||||
export interface LexwareContact {
|
||||
id: string;
|
||||
organizationId?: string;
|
||||
version: number;
|
||||
roles: LexwareContactRoles;
|
||||
company?: LexwareContactCompany;
|
||||
person?: LexwareContactPerson;
|
||||
addresses?: {
|
||||
billing?: LexwareAddress[];
|
||||
shipping?: LexwareAddress[];
|
||||
};
|
||||
emailAddresses?: LexwareEmailAddresses;
|
||||
phoneNumbers?: LexwarePhoneNumbers;
|
||||
note?: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface LexwareContactListResponse {
|
||||
content: LexwareContact[];
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export interface LexwareContactCreateResponse {
|
||||
id: string;
|
||||
resourceUri: string;
|
||||
createdDate: string;
|
||||
updatedDate: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Voucher / Beleg Interfaces
|
||||
// --------------------------------------------------------
|
||||
|
||||
export interface LexwareVoucherListItem {
|
||||
voucherId: string;
|
||||
voucherType: string;
|
||||
voucherNumber: string;
|
||||
voucherDate: string;
|
||||
voucherStatus: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
contactId?: string;
|
||||
contactName?: string;
|
||||
}
|
||||
|
||||
export interface LexwareVoucherListResponse {
|
||||
content: LexwareVoucherListItem[];
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
size: number;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export interface LexwareLineItem {
|
||||
id?: string;
|
||||
type: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unitName?: string;
|
||||
unitPrice?: {
|
||||
currency: string;
|
||||
netAmount: number;
|
||||
grossAmount: number;
|
||||
taxRatePercentage: number;
|
||||
};
|
||||
lineItemAmount?: number;
|
||||
}
|
||||
|
||||
export interface LexwareTotalPrice {
|
||||
currency: string;
|
||||
totalNetAmount: number;
|
||||
totalGrossAmount: number;
|
||||
totalTaxAmount: number;
|
||||
totalDiscountAbsolute?: number;
|
||||
totalDiscountPercentage?: number;
|
||||
}
|
||||
|
||||
export interface LexwareVoucherAddress {
|
||||
contactId?: string;
|
||||
name?: string;
|
||||
supplement?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
zip?: string;
|
||||
countryCode?: string;
|
||||
contactPerson?: string;
|
||||
}
|
||||
|
||||
export interface LexwareVoucherDetail {
|
||||
id: string;
|
||||
organizationId?: string;
|
||||
voucherNumber?: string;
|
||||
voucherDate?: string;
|
||||
voucherStatus?: string;
|
||||
createdDate?: string;
|
||||
updatedDate?: string;
|
||||
version?: number;
|
||||
archived?: boolean;
|
||||
language?: string;
|
||||
address?: LexwareVoucherAddress;
|
||||
lineItems?: LexwareLineItem[];
|
||||
totalPrice?: LexwareTotalPrice;
|
||||
taxConditions?: {
|
||||
taxType?: string;
|
||||
};
|
||||
title?: string;
|
||||
introduction?: string;
|
||||
remark?: string;
|
||||
relatedVouchers?: Array<{
|
||||
id: string;
|
||||
voucherNumber: string;
|
||||
voucherType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Type alias fuer spezifische Voucher-Typen (gleiche Struktur)
|
||||
export type LexwareInvoiceDetail = LexwareVoucherDetail & {
|
||||
paymentConditions?: {
|
||||
paymentTermLabel?: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
shippingConditions?: {
|
||||
shippingConditionsLabel?: string;
|
||||
deliveryTerms?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LexwareQuotationDetail = LexwareVoucherDetail;
|
||||
export type LexwareOrderConfirmationDetail = LexwareVoucherDetail;
|
||||
export type LexwareCreditNoteDetail = LexwareVoucherDetail;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Event Subscription Interfaces
|
||||
// --------------------------------------------------------
|
||||
|
||||
export interface LexwareEventSubscription {
|
||||
subscriptionId: string;
|
||||
organizationId: string;
|
||||
createdDate: string;
|
||||
eventType: string;
|
||||
callbackUrl: string;
|
||||
}
|
||||
183
packages/crm-service/src/lexware/lexware-client.service.ts
Normal file
183
packages/crm-service/src/lexware/lexware-client.service.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// ============================================================
|
||||
// Lexware Office API - Rate-limited HTTP Client
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { AxiosError } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RateLimiter } from './utils/rate-limiter';
|
||||
|
||||
@Injectable()
|
||||
export class LexwareClientService implements OnModuleInit {
|
||||
private readonly logger = new Logger(LexwareClientService.name);
|
||||
private readonly rateLimiter: RateLimiter;
|
||||
private enabled = false;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
const rateLimit = this.config.get<number>('LEXWARE_RATE_LIMIT', 2);
|
||||
this.rateLimiter = new RateLimiter(rateLimit, rateLimit);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const apiKey = this.config.get<string>('LEXWARE_API_KEY');
|
||||
if (!apiKey) {
|
||||
this.logger.warn(
|
||||
'LEXWARE_API_KEY nicht konfiguriert. Lexware-Integration deaktiviert.',
|
||||
);
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = true;
|
||||
this.logger.log('Lexware Office Integration aktiviert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft ob die Lexware-Integration konfiguriert und erreichbar ist.
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health-Check: Prueft Lexware API Erreichbarkeit.
|
||||
*/
|
||||
async isHealthy(): Promise<'up' | 'down' | 'unconfigured'> {
|
||||
if (!this.enabled) return 'unconfigured';
|
||||
|
||||
try {
|
||||
await this.get<unknown>('/v1/profile');
|
||||
return 'up';
|
||||
} catch {
|
||||
return 'down';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET Request an Lexware API (rate-limited).
|
||||
*/
|
||||
async get<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
): Promise<T> {
|
||||
this.ensureEnabled();
|
||||
return this.executeWithRetry<T>('GET', path, undefined, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST Request an Lexware API (rate-limited).
|
||||
*/
|
||||
async post<T>(path: string, body: unknown): Promise<T> {
|
||||
this.ensureEnabled();
|
||||
return this.executeWithRetry<T>('POST', path, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT Request an Lexware API (rate-limited).
|
||||
*/
|
||||
async put<T>(path: string, body: unknown): Promise<T> {
|
||||
this.ensureEnabled();
|
||||
return this.executeWithRetry<T>('PUT', path, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE Request an Lexware API (rate-limited).
|
||||
*/
|
||||
async delete(path: string): Promise<void> {
|
||||
this.ensureEnabled();
|
||||
await this.executeWithRetry<void>('DELETE', path);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Internes: Retry-Logik mit Rate Limiting
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async executeWithRetry<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string | number | boolean>,
|
||||
retries = 3,
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
await this.rateLimiter.acquire();
|
||||
|
||||
const response = await firstValueFrom(
|
||||
method === 'GET'
|
||||
? this.httpService.get<T>(path, { params })
|
||||
: method === 'POST'
|
||||
? this.httpService.post<T>(path, body)
|
||||
: method === 'PUT'
|
||||
? this.httpService.put<T>(path, body)
|
||||
: this.httpService.delete<T>(path),
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const status = axiosError.response?.status;
|
||||
|
||||
// 429 Too Many Requests -> Backoff und Retry
|
||||
if (status === 429 && attempt < retries) {
|
||||
const backoffMs = Math.pow(2, attempt) * 1000; // 2s, 4s
|
||||
this.logger.warn(
|
||||
`Lexware Rate Limit erreicht (429). Warte ${backoffMs}ms (Versuch ${attempt}/${retries})`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 401/403 -> API Key Problem
|
||||
if (status === 401 || status === 403) {
|
||||
this.logger.error(
|
||||
`Lexware API Authentifizierungsfehler (${status}). API Key pruefen.`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
'Lexware Office API nicht erreichbar (Authentifizierung fehlgeschlagen)',
|
||||
);
|
||||
}
|
||||
|
||||
// Andere Fehler oder letzter Retry
|
||||
if (attempt === retries) {
|
||||
const msg =
|
||||
axiosError.response?.statusText ||
|
||||
axiosError.message ||
|
||||
'Unbekannter Fehler';
|
||||
this.logger.error(
|
||||
`Lexware API Fehler: ${method} ${path} -> ${status || 'NETWORK'}: ${msg}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Netzwerk-Fehler -> kurzer Backoff
|
||||
const backoffMs = attempt * 1000;
|
||||
this.logger.warn(
|
||||
`Lexware API Fehler (${status || 'NETWORK'}). Retry in ${backoffMs}ms (${attempt}/${retries})`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Sollte nicht erreicht werden
|
||||
throw new ServiceUnavailableException('Lexware API nicht erreichbar');
|
||||
}
|
||||
|
||||
private ensureEnabled(): void {
|
||||
if (!this.enabled) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Lexware Office Integration ist nicht konfiguriert. LEXWARE_API_KEY fehlt.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
214
packages/crm-service/src/lexware/lexware-contacts.controller.ts
Normal file
214
packages/crm-service/src/lexware/lexware-contacts.controller.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// ============================================================
|
||||
// Lexware Office - Contacts REST Controller
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import { singleResponse } from '../common/dto/pagination.dto';
|
||||
import { LexwareContactsService } from './lexware-contacts.service';
|
||||
import { SearchLexwareContactsDto } from './dto/search-lexware-contacts.dto';
|
||||
import {
|
||||
LinkLexwareToCompanyDto,
|
||||
LinkLexwareToContactDto,
|
||||
ImportLexwareContactDto,
|
||||
} from './dto/link-lexware-contact.dto';
|
||||
|
||||
@ApiTags('Lexware Office - Kontakte')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(TenantGuard)
|
||||
@Controller('lexware/contacts')
|
||||
export class LexwareContactsController {
|
||||
constructor(private readonly service: LexwareContactsService) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({
|
||||
summary: 'Lexware-Kontakte suchen (Proxy zu Lexware Office API)',
|
||||
})
|
||||
async search(@Query() query: SearchLexwareContactsDto) {
|
||||
const result = await this.service.searchContacts(query);
|
||||
return {
|
||||
success: true,
|
||||
data: result.content,
|
||||
pagination: {
|
||||
page: result.number + 1, // Lexware ist 0-basiert, CRM 1-basiert
|
||||
pageSize: result.size,
|
||||
total: result.totalElements,
|
||||
totalPages: result.totalPages,
|
||||
},
|
||||
meta: { timestamp: new Date().toISOString() },
|
||||
};
|
||||
}
|
||||
|
||||
@Post('link-company')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Lexware-Kontakt mit CRM-Unternehmen verknuepfen',
|
||||
})
|
||||
async linkCompany(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: LinkLexwareToCompanyDto,
|
||||
) {
|
||||
const result = await this.service.linkToCompany(
|
||||
user.tenantId!,
|
||||
dto.companyId,
|
||||
dto.lexwareContactId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Post('link-contact')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Lexware-Kontakt mit CRM-Kontakt verknuepfen',
|
||||
})
|
||||
async linkContact(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: LinkLexwareToContactDto,
|
||||
) {
|
||||
const result = await this.service.linkToContact(
|
||||
user.tenantId!,
|
||||
dto.contactId,
|
||||
dto.lexwareContactId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Delete('unlink-company/:companyId')
|
||||
@ApiOperation({ summary: 'Lexware-Verknuepfung von Unternehmen loesen' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
async unlinkCompany(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
const result = await this.service.unlinkCompany(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Delete('unlink-contact/:contactId')
|
||||
@ApiOperation({ summary: 'Lexware-Verknuepfung von Kontakt loesen' })
|
||||
@ApiParam({ name: 'contactId', type: 'string', format: 'uuid' })
|
||||
async unlinkContact(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
) {
|
||||
const result = await this.service.unlinkContact(
|
||||
user.tenantId!,
|
||||
contactId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Post('import-company')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Neues CRM-Unternehmen aus Lexware-Kontakt erstellen',
|
||||
})
|
||||
async importCompany(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ImportLexwareContactDto,
|
||||
) {
|
||||
const result = await this.service.importAsCompany(
|
||||
user.tenantId!,
|
||||
dto.lexwareContactId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Post('import-contact')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Neuen CRM-Kontakt aus Lexware-Kontakt erstellen',
|
||||
})
|
||||
async importContact(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ImportLexwareContactDto,
|
||||
) {
|
||||
const result = await this.service.importAsContact(
|
||||
user.tenantId!,
|
||||
dto.lexwareContactId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Post('push/:entityType/:entityId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'CRM-Entity nach Lexware pushen (ERP)' })
|
||||
@ApiParam({ name: 'entityType', enum: ['company', 'contact'] })
|
||||
@ApiParam({ name: 'entityId', type: 'string', format: 'uuid' })
|
||||
async push(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
) {
|
||||
if (entityType === 'company') {
|
||||
const result = await this.service.pushCompanyToLexware(
|
||||
user.tenantId!,
|
||||
entityId,
|
||||
);
|
||||
return singleResponse(result);
|
||||
} else {
|
||||
const result = await this.service.pushContactToLexware(
|
||||
user.tenantId!,
|
||||
entityId,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('sync/:entityType/:entityId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Daten aus Lexware in CRM-Entity aktualisieren',
|
||||
})
|
||||
@ApiParam({ name: 'entityType', enum: ['company', 'contact'] })
|
||||
@ApiParam({ name: 'entityId', type: 'string', format: 'uuid' })
|
||||
async sync(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
) {
|
||||
if (entityType === 'company') {
|
||||
const result = await this.service.syncCompanyFromLexware(
|
||||
user.tenantId!,
|
||||
entityId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
} else {
|
||||
const result = await this.service.syncContactFromLexware(
|
||||
user.tenantId!,
|
||||
entityId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
452
packages/crm-service/src/lexware/lexware-contacts.service.ts
Normal file
452
packages/crm-service/src/lexware/lexware-contacts.service.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
// ============================================================
|
||||
// Lexware Office - Contact Operations Service
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { LexwareClientService } from './lexware-client.service';
|
||||
import { SearchLexwareContactsDto } from './dto/search-lexware-contacts.dto';
|
||||
import {
|
||||
LexwareContact,
|
||||
LexwareContactListResponse,
|
||||
LexwareContactCreateResponse,
|
||||
} from './interfaces/lexware-api.interfaces';
|
||||
import {
|
||||
lexwareContactToCompanyData,
|
||||
lexwareContactToContactData,
|
||||
companyToLexwareContactBody,
|
||||
contactToLexwareContactBody,
|
||||
} from './utils/lexware-mapper';
|
||||
|
||||
@Injectable()
|
||||
export class LexwareContactsService {
|
||||
private readonly logger = new Logger(LexwareContactsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareClient: LexwareClientService,
|
||||
) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Suche in Lexware Office
|
||||
// --------------------------------------------------------
|
||||
|
||||
async searchContacts(
|
||||
query: SearchLexwareContactsDto,
|
||||
): Promise<LexwareContactListResponse> {
|
||||
const params: Record<string, string | number | boolean> = {};
|
||||
|
||||
if (query.name) params.name = query.name;
|
||||
if (query.email) params.email = query.email;
|
||||
if (query.number !== undefined) params.number = query.number;
|
||||
if (query.customer !== undefined) params.customer = query.customer;
|
||||
if (query.vendor !== undefined) params.vendor = query.vendor;
|
||||
if (query.page !== undefined) params.page = query.page;
|
||||
if (query.size !== undefined) params.size = query.size;
|
||||
|
||||
return this.lexwareClient.get<LexwareContactListResponse>(
|
||||
'/v1/contacts',
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
async getContact(lexwareContactId: string): Promise<LexwareContact> {
|
||||
return this.lexwareClient.get<LexwareContact>(
|
||||
`/v1/contacts/${lexwareContactId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Link Lexware Contact -> CRM Company
|
||||
// --------------------------------------------------------
|
||||
|
||||
async linkToCompany(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
lexwareContactId: string,
|
||||
userId: string,
|
||||
) {
|
||||
// Pruefe ob Company existiert
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
// Pruefe ob Lexware-Kontakt bereits verknuepft
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: { tenantId, lexwareContactId },
|
||||
});
|
||||
if (existing && existing.id !== companyId) {
|
||||
throw new ConflictException(
|
||||
`Lexware-Kontakt ist bereits mit Unternehmen "${existing.name}" verknuepft`,
|
||||
);
|
||||
}
|
||||
|
||||
// Auch bei Contacts pruefen
|
||||
const existingContact = await this.prisma.contact.findFirst({
|
||||
where: { tenantId, lexwareContactId },
|
||||
});
|
||||
if (existingContact) {
|
||||
throw new ConflictException(
|
||||
`Lexware-Kontakt ist bereits mit Kontakt "${existingContact.firstName} ${existingContact.lastName}" verknuepft`,
|
||||
);
|
||||
}
|
||||
|
||||
// Lexware-Kontakt laden und Version speichern
|
||||
const lexwareContact = await this.getContact(lexwareContactId);
|
||||
|
||||
return this.prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
lexwareContactId,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Link Lexware Contact -> CRM Contact
|
||||
// --------------------------------------------------------
|
||||
|
||||
async linkToContact(
|
||||
tenantId: string,
|
||||
contactId: string,
|
||||
lexwareContactId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
|
||||
// Pruefe ob Lexware-Kontakt bereits verknuepft
|
||||
const existingContact = await this.prisma.contact.findFirst({
|
||||
where: { tenantId, lexwareContactId },
|
||||
});
|
||||
if (existingContact && existingContact.id !== contactId) {
|
||||
throw new ConflictException(
|
||||
`Lexware-Kontakt ist bereits mit Kontakt "${existingContact.firstName} ${existingContact.lastName}" verknuepft`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingCompany = await this.prisma.company.findFirst({
|
||||
where: { tenantId, lexwareContactId },
|
||||
});
|
||||
if (existingCompany) {
|
||||
throw new ConflictException(
|
||||
`Lexware-Kontakt ist bereits mit Unternehmen "${existingCompany.name}" verknuepft`,
|
||||
);
|
||||
}
|
||||
|
||||
const lexwareContact = await this.getContact(lexwareContactId);
|
||||
|
||||
return this.prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
lexwareContactId,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Unlink
|
||||
// --------------------------------------------------------
|
||||
|
||||
async unlinkCompany(tenantId: string, companyId: string, userId: string) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
lexwareContactId: null,
|
||||
lexwareContactVersion: null,
|
||||
lexwareSyncedAt: null,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkContact(tenantId: string, contactId: string, userId: string) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
lexwareContactId: null,
|
||||
lexwareContactVersion: null,
|
||||
lexwareSyncedAt: null,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Import: Lexware -> Neue CRM Entity
|
||||
// --------------------------------------------------------
|
||||
|
||||
async importAsCompany(
|
||||
tenantId: string,
|
||||
lexwareContactId: string,
|
||||
userId: string,
|
||||
) {
|
||||
// Pruefe ob bereits verknuepft
|
||||
const existing = await this.prisma.company.findFirst({
|
||||
where: { tenantId, lexwareContactId },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Lexware-Kontakt ist bereits mit Unternehmen "${existing.name}" verknuepft`,
|
||||
);
|
||||
}
|
||||
|
||||
const lexwareContact = await this.getContact(lexwareContactId);
|
||||
const companyData = lexwareContactToCompanyData(lexwareContact);
|
||||
|
||||
return this.prisma.company.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...companyData,
|
||||
lexwareContactId,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
createdBy: userId,
|
||||
},
|
||||
include: {
|
||||
_count: { select: { contacts: true, deals: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async importAsContact(
|
||||
tenantId: string,
|
||||
lexwareContactId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const existing = await this.prisma.contact.findFirst({
|
||||
where: { tenantId, lexwareContactId },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Lexware-Kontakt ist bereits mit Kontakt "${existing.firstName} ${existing.lastName}" verknuepft`,
|
||||
);
|
||||
}
|
||||
|
||||
const lexwareContact = await this.getContact(lexwareContactId);
|
||||
const contactData = lexwareContactToContactData(lexwareContact);
|
||||
|
||||
return this.prisma.contact.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...contactData,
|
||||
lexwareContactId,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Push: CRM -> Lexware (ERP-Tag)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async pushCompanyToLexware(tenantId: string, companyId: string) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
const body = companyToLexwareContactBody(company);
|
||||
|
||||
if (company.lexwareContactId) {
|
||||
// Update: Hole aktuelle Version von Lexware
|
||||
const current = await this.getContact(company.lexwareContactId);
|
||||
const updateBody = { ...body, version: current.version };
|
||||
|
||||
try {
|
||||
await this.lexwareClient.put(
|
||||
`/v1/contacts/${company.lexwareContactId}`,
|
||||
updateBody,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
this.logger.warn(
|
||||
`Optimistic Locking Konflikt beim Update von Lexware-Kontakt ${company.lexwareContactId}. Ueberspringe.`,
|
||||
);
|
||||
return company;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
lexwareContactVersion: current.version + 1,
|
||||
lexwareSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create: Neuen Lexware-Kontakt erstellen
|
||||
const result =
|
||||
await this.lexwareClient.post<LexwareContactCreateResponse>(
|
||||
'/v1/contacts',
|
||||
body,
|
||||
);
|
||||
|
||||
return this.prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
lexwareContactId: result.id,
|
||||
lexwareContactVersion: result.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async pushContactToLexware(tenantId: string, contactId: string) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
|
||||
const body = contactToLexwareContactBody(contact);
|
||||
|
||||
if (contact.lexwareContactId) {
|
||||
const current = await this.getContact(contact.lexwareContactId);
|
||||
const updateBody = { ...body, version: current.version };
|
||||
|
||||
try {
|
||||
await this.lexwareClient.put(
|
||||
`/v1/contacts/${contact.lexwareContactId}`,
|
||||
updateBody,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
if (axiosError.response?.status === 409) {
|
||||
this.logger.warn(
|
||||
`Optimistic Locking Konflikt beim Update von Lexware-Kontakt ${contact.lexwareContactId}. Ueberspringe.`,
|
||||
);
|
||||
return contact;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
lexwareContactVersion: current.version + 1,
|
||||
lexwareSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const result =
|
||||
await this.lexwareClient.post<LexwareContactCreateResponse>(
|
||||
'/v1/contacts',
|
||||
body,
|
||||
);
|
||||
|
||||
return this.prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
lexwareContactId: result.id,
|
||||
lexwareContactVersion: result.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Sync: Lexware -> CRM (Daten aktualisieren)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async syncCompanyFromLexware(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
if (!company.lexwareContactId) {
|
||||
throw new NotFoundException(
|
||||
'Unternehmen ist nicht mit Lexware verknuepft',
|
||||
);
|
||||
}
|
||||
|
||||
const lexwareContact = await this.getContact(company.lexwareContactId);
|
||||
const companyData = lexwareContactToCompanyData(lexwareContact);
|
||||
|
||||
return this.prisma.company.update({
|
||||
where: { id: companyId },
|
||||
data: {
|
||||
...companyData,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async syncContactFromLexware(
|
||||
tenantId: string,
|
||||
contactId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
if (!contact.lexwareContactId) {
|
||||
throw new NotFoundException('Kontakt ist nicht mit Lexware verknuepft');
|
||||
}
|
||||
|
||||
const lexwareContact = await this.getContact(contact.lexwareContactId);
|
||||
const contactData = lexwareContactToContactData(lexwareContact);
|
||||
|
||||
return this.prisma.contact.update({
|
||||
where: { id: contactId },
|
||||
data: {
|
||||
...contactData,
|
||||
lexwareContactVersion: lexwareContact.version,
|
||||
lexwareSyncedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
210
packages/crm-service/src/lexware/lexware-sync.service.ts
Normal file
210
packages/crm-service/src/lexware/lexware-sync.service.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// ============================================================
|
||||
// Lexware Office - Sync Jobs (Cron)
|
||||
// ============================================================
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { LexwareClientService } from './lexware-client.service';
|
||||
import { LexwareContactsService } from './lexware-contacts.service';
|
||||
import { LexwareVouchersService } from './lexware-vouchers.service';
|
||||
|
||||
@Injectable()
|
||||
export class LexwareSyncService {
|
||||
private readonly logger = new Logger(LexwareSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly lexwareClient: LexwareClientService,
|
||||
private readonly contactsService: LexwareContactsService,
|
||||
private readonly vouchersService: LexwareVouchersService,
|
||||
) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Job 1: Beleg-Cache aktualisieren (alle 4 Stunden)
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Cron(CronExpression.EVERY_4_HOURS)
|
||||
async syncVouchers(): Promise<void> {
|
||||
if (!this.lexwareClient.isEnabled()) return;
|
||||
|
||||
// Distributed Lock: Nur eine Instanz gleichzeitig
|
||||
const lockKey = 'lexware:sync:vouchers:lock';
|
||||
const acquired = await this.acquireLock(lockKey, 1800); // 30 Min Lock
|
||||
if (!acquired) {
|
||||
this.logger.log('Beleg-Sync: Lock nicht erhalten. Ueberspringe.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log('Starte Beleg-Sync fuer alle verknuepften Entitaeten...');
|
||||
|
||||
// Alle Companies mit Lexware-Verknuepfung
|
||||
const companies = await this.prisma.company.findMany({
|
||||
where: { lexwareContactId: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
lexwareContactId: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const company of companies) {
|
||||
try {
|
||||
await this.vouchersService.fetchAndCacheVouchers(
|
||||
company.tenantId,
|
||||
company.lexwareContactId!,
|
||||
company.id,
|
||||
null,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Beleg-Sync fehlgeschlagen fuer Company "${company.name}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Contacts mit Lexware-Verknuepfung
|
||||
const contacts = await this.prisma.contact.findMany({
|
||||
where: { lexwareContactId: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
lexwareContactId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const contact of contacts) {
|
||||
try {
|
||||
await this.vouchersService.fetchAndCacheVouchers(
|
||||
contact.tenantId,
|
||||
contact.lexwareContactId!,
|
||||
null,
|
||||
contact.id,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Beleg-Sync fehlgeschlagen fuer Contact "${contact.firstName} ${contact.lastName}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Beleg-Sync abgeschlossen. ${companies.length} Companies, ${contacts.length} Contacts verarbeitet.`,
|
||||
);
|
||||
} finally {
|
||||
await this.releaseLock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Job 2: ERP-Tag Push (alle 30 Minuten)
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Cron('0 */30 * * * *')
|
||||
async pushErpTaggedEntities(): Promise<void> {
|
||||
if (!this.lexwareClient.isEnabled()) return;
|
||||
|
||||
const lockKey = 'lexware:sync:push:lock';
|
||||
const acquired = await this.acquireLock(lockKey, 900); // 15 Min Lock
|
||||
if (!acquired) {
|
||||
this.logger.log('ERP-Push: Lock nicht erhalten. Ueberspringe.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log('Starte ERP-Push fuer getaggte Entitaeten...');
|
||||
|
||||
// Companies mit "ERP" Tag
|
||||
const companies = await this.prisma.company.findMany({
|
||||
where: {
|
||||
tags: { has: 'ERP' },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
name: true,
|
||||
lexwareContactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
let pushCount = 0;
|
||||
for (const company of companies) {
|
||||
try {
|
||||
await this.contactsService.pushCompanyToLexware(
|
||||
company.tenantId,
|
||||
company.id,
|
||||
);
|
||||
pushCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`ERP-Push fehlgeschlagen fuer Company "${company.name}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Contacts mit "ERP" Tag
|
||||
const contacts = await this.prisma.contact.findMany({
|
||||
where: {
|
||||
tags: { has: 'ERP' },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
lexwareContactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const contact of contacts) {
|
||||
try {
|
||||
await this.contactsService.pushContactToLexware(
|
||||
contact.tenantId,
|
||||
contact.id,
|
||||
);
|
||||
pushCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`ERP-Push fehlgeschlagen fuer Contact "${contact.firstName} ${contact.lastName}": ${error instanceof Error ? error.message : 'Unbekannt'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`ERP-Push abgeschlossen. ${pushCount} von ${companies.length + contacts.length} Entitaeten gepusht.`,
|
||||
);
|
||||
} finally {
|
||||
await this.releaseLock(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Distributed Lock (Redis SET NX EX)
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async acquireLock(
|
||||
key: string,
|
||||
ttlSeconds: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redis.setNx(key, 'locked', ttlSeconds);
|
||||
return result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseLock(key: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.del(key);
|
||||
} catch {
|
||||
// Lock wird durch TTL automatisch freigegeben
|
||||
}
|
||||
}
|
||||
}
|
||||
190
packages/crm-service/src/lexware/lexware-vouchers.controller.ts
Normal file
190
packages/crm-service/src/lexware/lexware-vouchers.controller.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// ============================================================
|
||||
// Lexware Office - Vouchers REST Controller
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { TenantGuard } from '../auth/guards/tenant.guard';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators';
|
||||
import {
|
||||
paginatedResponse,
|
||||
singleResponse,
|
||||
} from '../common/dto/pagination.dto';
|
||||
import { LexwareVouchersService } from './lexware-vouchers.service';
|
||||
import { QueryLexwareVouchersDto } from './dto/query-lexware-vouchers.dto';
|
||||
import { LinkDealVoucherDto } from './dto/link-deal-voucher.dto';
|
||||
|
||||
@ApiTags('Lexware Office - Belege')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(TenantGuard)
|
||||
@Controller('lexware/vouchers')
|
||||
export class LexwareVouchersController {
|
||||
constructor(private readonly service: LexwareVouchersService) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Belege abrufen
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Get('company/:companyId')
|
||||
@ApiOperation({ summary: 'Lexware-Belege fuer Unternehmen abrufen (Cache)' })
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
async getForCompany(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Query() query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const result = await this.service.getVouchersForCompany(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
query,
|
||||
);
|
||||
return paginatedResponse(
|
||||
result.data,
|
||||
result.total,
|
||||
result.page,
|
||||
result.pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('contact/:contactId')
|
||||
@ApiOperation({ summary: 'Lexware-Belege fuer Kontakt abrufen (Cache)' })
|
||||
@ApiParam({ name: 'contactId', type: 'string', format: 'uuid' })
|
||||
async getForContact(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
@Query() query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const result = await this.service.getVouchersForContact(
|
||||
user.tenantId!,
|
||||
contactId,
|
||||
query,
|
||||
);
|
||||
return paginatedResponse(
|
||||
result.data,
|
||||
result.total,
|
||||
result.page,
|
||||
result.pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('deal/:dealId')
|
||||
@ApiOperation({ summary: 'Lexware-Belege fuer Vorgang abrufen' })
|
||||
@ApiParam({ name: 'dealId', type: 'string', format: 'uuid' })
|
||||
async getForDeal(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('dealId', ParseUUIDPipe) dealId: string,
|
||||
@Query() query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const result = await this.service.getVouchersForDeal(
|
||||
user.tenantId!,
|
||||
dealId,
|
||||
query,
|
||||
);
|
||||
return paginatedResponse(
|
||||
result.data,
|
||||
result.total,
|
||||
result.page,
|
||||
result.pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Beleg mit Vorgang verknuepfen / trennen
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Post('deal/:dealId/link')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Lexware-Beleg mit Vorgang verknuepfen' })
|
||||
@ApiParam({ name: 'dealId', type: 'string', format: 'uuid' })
|
||||
async linkToDeal(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('dealId', ParseUUIDPipe) dealId: string,
|
||||
@Body() dto: LinkDealVoucherDto,
|
||||
) {
|
||||
const result = await this.service.linkVoucherToDeal(
|
||||
user.tenantId!,
|
||||
dealId,
|
||||
dto.voucherId,
|
||||
user.sub,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
@Delete('deal/:dealId/unlink/:voucherId')
|
||||
@ApiOperation({ summary: 'Lexware-Beleg von Vorgang trennen' })
|
||||
@ApiParam({ name: 'dealId', type: 'string', format: 'uuid' })
|
||||
@ApiParam({ name: 'voucherId', type: 'string', format: 'uuid' })
|
||||
async unlinkFromDeal(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('dealId', ParseUUIDPipe) dealId: string,
|
||||
@Param('voucherId', ParseUUIDPipe) voucherId: string,
|
||||
) {
|
||||
const result = await this.service.unlinkVoucherFromDeal(
|
||||
user.tenantId!,
|
||||
dealId,
|
||||
voucherId,
|
||||
);
|
||||
return singleResponse(result);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Cache manuell aktualisieren
|
||||
// --------------------------------------------------------
|
||||
|
||||
@Post('refresh/company/:companyId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Beleg-Cache fuer Unternehmen manuell aktualisieren',
|
||||
})
|
||||
@ApiParam({ name: 'companyId', type: 'string', format: 'uuid' })
|
||||
async refreshCompany(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
const count = await this.service.refreshCompanyVouchers(
|
||||
user.tenantId!,
|
||||
companyId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: { refreshedCount: count },
|
||||
meta: { timestamp: new Date().toISOString() },
|
||||
};
|
||||
}
|
||||
|
||||
@Post('refresh/contact/:contactId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Beleg-Cache fuer Kontakt manuell aktualisieren' })
|
||||
@ApiParam({ name: 'contactId', type: 'string', format: 'uuid' })
|
||||
async refreshContact(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
) {
|
||||
const count = await this.service.refreshContactVouchers(
|
||||
user.tenantId!,
|
||||
contactId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: { refreshedCount: count },
|
||||
meta: { timestamp: new Date().toISOString() },
|
||||
};
|
||||
}
|
||||
}
|
||||
381
packages/crm-service/src/lexware/lexware-vouchers.service.ts
Normal file
381
packages/crm-service/src/lexware/lexware-vouchers.service.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
// ============================================================
|
||||
// Lexware Office - Voucher Operations Service
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '.prisma/crm-client';
|
||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||
import { LexwareClientService } from './lexware-client.service';
|
||||
import { QueryLexwareVouchersDto } from './dto/query-lexware-vouchers.dto';
|
||||
import {
|
||||
LexwareVoucherListResponse,
|
||||
LexwareVoucherDetail,
|
||||
} from './interfaces/lexware-api.interfaces';
|
||||
import {
|
||||
voucherTypeFromLexware,
|
||||
voucherTypeToLexwareEndpoint,
|
||||
voucherDetailToCacheData,
|
||||
} from './utils/lexware-mapper';
|
||||
|
||||
@Injectable()
|
||||
export class LexwareVouchersService {
|
||||
private readonly logger = new Logger(LexwareVouchersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: CrmPrismaService,
|
||||
private readonly lexwareClient: LexwareClientService,
|
||||
) {}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Belege von Lexware laden und cachen
|
||||
// --------------------------------------------------------
|
||||
|
||||
async fetchAndCacheVouchers(
|
||||
tenantId: string,
|
||||
lexwareContactId: string,
|
||||
companyId?: string | null,
|
||||
contactId?: string | null,
|
||||
): Promise<number> {
|
||||
this.logger.log(
|
||||
`Lade Belege fuer Lexware-Kontakt ${lexwareContactId}...`,
|
||||
);
|
||||
|
||||
// Alle relevanten Belegtypen laden
|
||||
const voucherTypes = [
|
||||
'invoice',
|
||||
'quotation',
|
||||
'orderconfirmation',
|
||||
'creditnote',
|
||||
];
|
||||
let totalUpserted = 0;
|
||||
|
||||
for (const voucherType of voucherTypes) {
|
||||
let page = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const listResponse =
|
||||
await this.lexwareClient.get<LexwareVoucherListResponse>(
|
||||
'/v1/voucherlist',
|
||||
{
|
||||
contactId: lexwareContactId,
|
||||
voucherType,
|
||||
page,
|
||||
size: 100,
|
||||
},
|
||||
);
|
||||
|
||||
for (const item of listResponse.content) {
|
||||
try {
|
||||
// Detail laden fuer vollstaendige Daten
|
||||
const crmVoucherType = voucherTypeFromLexware(item.voucherType);
|
||||
const endpoint = voucherTypeToLexwareEndpoint(crmVoucherType);
|
||||
const detail =
|
||||
await this.lexwareClient.get<LexwareVoucherDetail>(
|
||||
`/v1/${endpoint}/${item.voucherId}`,
|
||||
);
|
||||
|
||||
const cacheData = voucherDetailToCacheData(
|
||||
detail,
|
||||
crmVoucherType,
|
||||
lexwareContactId,
|
||||
);
|
||||
|
||||
// Upsert: Existierenden Beleg aktualisieren oder neuen anlegen
|
||||
await this.prisma.lexwareVoucher.upsert({
|
||||
where: {
|
||||
tenantId_lexwareVoucherId: {
|
||||
tenantId,
|
||||
lexwareVoucherId: item.voucherId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenantId,
|
||||
lexwareVoucherId: item.voucherId,
|
||||
...cacheData,
|
||||
companyId: companyId || undefined,
|
||||
contactId: contactId || undefined,
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
...cacheData,
|
||||
companyId: companyId || undefined,
|
||||
contactId: contactId || undefined,
|
||||
fetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
totalUpserted++;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Fehler beim Laden von Beleg ${item.voucherId}: ${error instanceof Error ? error.message : 'Unbekannt'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = !listResponse.last;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${totalUpserted} Belege fuer Lexware-Kontakt ${lexwareContactId} gecacht.`,
|
||||
);
|
||||
return totalUpserted;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Belege fuer Company abrufen (aus Cache)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async getVouchersForCompany(
|
||||
tenantId: string,
|
||||
companyId: string,
|
||||
query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
|
||||
return this.queryVouchers(tenantId, { companyId }, query);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Belege fuer Contact abrufen (aus Cache)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async getVouchersForContact(
|
||||
tenantId: string,
|
||||
contactId: string,
|
||||
query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
|
||||
return this.queryVouchers(tenantId, { contactId }, query);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Belege fuer Deal abrufen (ueber DealVoucher Join-Table)
|
||||
// --------------------------------------------------------
|
||||
|
||||
async getVouchersForDeal(
|
||||
tenantId: string,
|
||||
dealId: string,
|
||||
query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, tenantId },
|
||||
});
|
||||
if (!deal) {
|
||||
throw new NotFoundException('Vorgang nicht gefunden');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 25;
|
||||
|
||||
const where: Prisma.DealVoucherWhereInput = {
|
||||
tenantId,
|
||||
dealId,
|
||||
};
|
||||
|
||||
// Filter auf Voucher-Ebene
|
||||
const voucherWhere: Prisma.LexwareVoucherWhereInput = {};
|
||||
if (query.voucherType) {
|
||||
voucherWhere.voucherType = query.voucherType;
|
||||
}
|
||||
if (query.voucherStatus) {
|
||||
voucherWhere.voucherStatus = query.voucherStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(voucherWhere).length > 0) {
|
||||
where.voucher = voucherWhere;
|
||||
}
|
||||
|
||||
const [dealVouchers, total] = await Promise.all([
|
||||
this.prisma.dealVoucher.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { linkedAt: 'desc' },
|
||||
include: {
|
||||
voucher: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.dealVoucher.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: dealVouchers.map((dv) => ({
|
||||
...dv.voucher,
|
||||
linkedBy: dv.linkedBy,
|
||||
linkedAt: dv.linkedAt,
|
||||
dealVoucherId: dv.id,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Beleg mit Deal verknuepfen
|
||||
// --------------------------------------------------------
|
||||
|
||||
async linkVoucherToDeal(
|
||||
tenantId: string,
|
||||
dealId: string,
|
||||
voucherId: string,
|
||||
userId: string,
|
||||
) {
|
||||
// Deal pruefen
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, tenantId },
|
||||
});
|
||||
if (!deal) {
|
||||
throw new NotFoundException('Vorgang nicht gefunden');
|
||||
}
|
||||
|
||||
// Voucher pruefen
|
||||
const voucher = await this.prisma.lexwareVoucher.findFirst({
|
||||
where: { id: voucherId, tenantId },
|
||||
});
|
||||
if (!voucher) {
|
||||
throw new NotFoundException('Beleg nicht gefunden');
|
||||
}
|
||||
|
||||
// Pruefe ob bereits verknuepft
|
||||
const existing = await this.prisma.dealVoucher.findUnique({
|
||||
where: { dealId_voucherId: { dealId, voucherId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Beleg ist bereits mit diesem Vorgang verknuepft');
|
||||
}
|
||||
|
||||
return this.prisma.dealVoucher.create({
|
||||
data: {
|
||||
tenantId,
|
||||
dealId,
|
||||
voucherId,
|
||||
linkedBy: userId,
|
||||
},
|
||||
include: {
|
||||
voucher: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Beleg von Deal trennen
|
||||
// --------------------------------------------------------
|
||||
|
||||
async unlinkVoucherFromDeal(
|
||||
tenantId: string,
|
||||
dealId: string,
|
||||
voucherId: string,
|
||||
) {
|
||||
const dealVoucher = await this.prisma.dealVoucher.findFirst({
|
||||
where: { tenantId, dealId, voucherId },
|
||||
});
|
||||
if (!dealVoucher) {
|
||||
throw new NotFoundException('Verknuepfung nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.dealVoucher.delete({
|
||||
where: { id: dealVoucher.id },
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Cache manuell aktualisieren
|
||||
// --------------------------------------------------------
|
||||
|
||||
async refreshCompanyVouchers(tenantId: string, companyId: string) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: companyId, tenantId },
|
||||
});
|
||||
if (!company) {
|
||||
throw new NotFoundException('Unternehmen nicht gefunden');
|
||||
}
|
||||
if (!company.lexwareContactId) {
|
||||
throw new NotFoundException(
|
||||
'Unternehmen ist nicht mit Lexware verknuepft',
|
||||
);
|
||||
}
|
||||
|
||||
return this.fetchAndCacheVouchers(
|
||||
tenantId,
|
||||
company.lexwareContactId,
|
||||
companyId,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async refreshContactVouchers(tenantId: string, contactId: string) {
|
||||
const contact = await this.prisma.contact.findFirst({
|
||||
where: { id: contactId, tenantId },
|
||||
});
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Kontakt nicht gefunden');
|
||||
}
|
||||
if (!contact.lexwareContactId) {
|
||||
throw new NotFoundException('Kontakt ist nicht mit Lexware verknuepft');
|
||||
}
|
||||
|
||||
return this.fetchAndCacheVouchers(
|
||||
tenantId,
|
||||
contact.lexwareContactId,
|
||||
null,
|
||||
contactId,
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Interne Hilfsfunktion: Paginierte Voucher-Abfrage
|
||||
// --------------------------------------------------------
|
||||
|
||||
private async queryVouchers(
|
||||
tenantId: string,
|
||||
filter: { companyId?: string; contactId?: string },
|
||||
query: QueryLexwareVouchersDto,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 25;
|
||||
|
||||
const where: Prisma.LexwareVoucherWhereInput = {
|
||||
tenantId,
|
||||
...filter,
|
||||
};
|
||||
|
||||
if (query.voucherType) {
|
||||
where.voucherType = query.voucherType;
|
||||
}
|
||||
if (query.voucherStatus) {
|
||||
where.voucherStatus = query.voucherStatus;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.lexwareVoucher.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { voucherDate: 'desc' },
|
||||
}),
|
||||
this.prisma.lexwareVoucher.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
46
packages/crm-service/src/lexware/lexware.module.ts
Normal file
46
packages/crm-service/src/lexware/lexware.module.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// ============================================================
|
||||
// Lexware Office Integration - NestJS Module
|
||||
// ============================================================
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { LexwareClientService } from './lexware-client.service';
|
||||
import { LexwareContactsService } from './lexware-contacts.service';
|
||||
import { LexwareVouchersService } from './lexware-vouchers.service';
|
||||
import { LexwareSyncService } from './lexware-sync.service';
|
||||
import { LexwareContactsController } from './lexware-contacts.controller';
|
||||
import { LexwareVouchersController } from './lexware-vouchers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
baseURL: config.get<string>(
|
||||
'LEXWARE_API_URL',
|
||||
'https://api.lexware.io',
|
||||
),
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.get<string>('LEXWARE_API_KEY', '')}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
CrmPrismaModule,
|
||||
RedisModule,
|
||||
],
|
||||
controllers: [LexwareContactsController, LexwareVouchersController],
|
||||
providers: [
|
||||
LexwareClientService,
|
||||
LexwareContactsService,
|
||||
LexwareVouchersService,
|
||||
LexwareSyncService,
|
||||
],
|
||||
exports: [LexwareContactsService, LexwareVouchersService, LexwareClientService],
|
||||
})
|
||||
export class LexwareModule {}
|
||||
334
packages/crm-service/src/lexware/utils/lexware-mapper.ts
Normal file
334
packages/crm-service/src/lexware/utils/lexware-mapper.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// ============================================================
|
||||
// Mapping-Funktionen: Lexware Office <-> CRM
|
||||
// ============================================================
|
||||
|
||||
import { VoucherType } from '.prisma/crm-client';
|
||||
import {
|
||||
LexwareContact,
|
||||
LexwareVoucherDetail,
|
||||
LexwareVoucherListItem,
|
||||
} from '../interfaces/lexware-api.interfaces';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware Contact -> CRM Company
|
||||
// --------------------------------------------------------
|
||||
export function lexwareContactToCompanyData(lc: LexwareContact): {
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
notes?: string;
|
||||
} {
|
||||
const name =
|
||||
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
|
||||
|
||||
const billingAddr = lc.addresses?.billing?.[0];
|
||||
const email = getFirstEmail(lc);
|
||||
const phone = getFirstPhone(lc);
|
||||
|
||||
return {
|
||||
name,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
street: billingAddr?.street || undefined,
|
||||
zip: billingAddr?.zip || undefined,
|
||||
city: billingAddr?.city || undefined,
|
||||
country: billingAddr?.countryCode || 'DE',
|
||||
notes: lc.note || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware Contact -> CRM Contact
|
||||
// --------------------------------------------------------
|
||||
export function lexwareContactToContactData(lc: LexwareContact): {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
companyName?: string;
|
||||
position?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
notes?: string;
|
||||
type: 'PERSON' | 'ORGANIZATION';
|
||||
} {
|
||||
const isPerson = !!lc.person;
|
||||
const billingAddr = lc.addresses?.billing?.[0];
|
||||
const email = getFirstEmail(lc);
|
||||
const phone = getFirstPhone(lc);
|
||||
const mobile = lc.phoneNumbers?.mobile?.[0];
|
||||
|
||||
if (isPerson) {
|
||||
return {
|
||||
type: 'PERSON',
|
||||
firstName: lc.person?.firstName || undefined,
|
||||
lastName: lc.person?.lastName || 'Unbekannt',
|
||||
companyName: lc.company?.name || undefined,
|
||||
position: lc.company?.contactPersons?.[0]?.primary
|
||||
? 'Primaerkontakt'
|
||||
: undefined,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
mobile: mobile || undefined,
|
||||
street: billingAddr?.street || undefined,
|
||||
zip: billingAddr?.zip || undefined,
|
||||
city: billingAddr?.city || undefined,
|
||||
country: billingAddr?.countryCode || 'DE',
|
||||
notes: lc.note || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'ORGANIZATION',
|
||||
firstName: lc.company?.contactPersons?.[0]?.firstName || undefined,
|
||||
lastName:
|
||||
lc.company?.contactPersons?.[0]?.lastName || lc.company?.name || 'Unbekannt',
|
||||
companyName: lc.company?.name || undefined,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
mobile: mobile || undefined,
|
||||
street: billingAddr?.street || undefined,
|
||||
zip: billingAddr?.zip || undefined,
|
||||
city: billingAddr?.city || undefined,
|
||||
country: billingAddr?.countryCode || 'DE',
|
||||
notes: lc.note || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// CRM Company -> Lexware Contact Body (fuer POST/PUT)
|
||||
// --------------------------------------------------------
|
||||
export function companyToLexwareContactBody(company: {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
street?: string | null;
|
||||
zip?: string | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
notes?: string | null;
|
||||
}): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 0,
|
||||
roles: { customer: {} },
|
||||
company: { name: company.name },
|
||||
};
|
||||
|
||||
if (company.street || company.zip || company.city) {
|
||||
body.addresses = {
|
||||
billing: [
|
||||
{
|
||||
street: company.street || undefined,
|
||||
zip: company.zip || undefined,
|
||||
city: company.city || undefined,
|
||||
countryCode: company.country || 'DE',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (company.email) {
|
||||
body.emailAddresses = { business: [company.email] };
|
||||
}
|
||||
|
||||
if (company.phone) {
|
||||
body.phoneNumbers = { business: [company.phone] };
|
||||
}
|
||||
|
||||
if (company.notes) {
|
||||
body.note = company.notes.substring(0, 1000);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// CRM Contact -> Lexware Contact Body (fuer POST/PUT)
|
||||
// --------------------------------------------------------
|
||||
export function contactToLexwareContactBody(contact: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
companyName?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
mobile?: string | null;
|
||||
street?: string | null;
|
||||
zip?: string | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
notes?: string | null;
|
||||
type: string;
|
||||
}): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 0,
|
||||
roles: { customer: {} },
|
||||
};
|
||||
|
||||
if (contact.type === 'ORGANIZATION' && contact.companyName) {
|
||||
body.company = { name: contact.companyName };
|
||||
} else {
|
||||
body.person = {
|
||||
firstName: contact.firstName || undefined,
|
||||
lastName: contact.lastName || 'Unbekannt',
|
||||
};
|
||||
}
|
||||
|
||||
if (contact.street || contact.zip || contact.city) {
|
||||
body.addresses = {
|
||||
billing: [
|
||||
{
|
||||
street: contact.street || undefined,
|
||||
zip: contact.zip || undefined,
|
||||
city: contact.city || undefined,
|
||||
countryCode: contact.country || 'DE',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (contact.email) {
|
||||
body.emailAddresses = { business: [contact.email] };
|
||||
}
|
||||
|
||||
const phoneNumbers: Record<string, string[]> = {};
|
||||
if (contact.phone) phoneNumbers.business = [contact.phone];
|
||||
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
|
||||
if (Object.keys(phoneNumbers).length > 0) {
|
||||
body.phoneNumbers = phoneNumbers;
|
||||
}
|
||||
|
||||
if (contact.notes) {
|
||||
body.note = contact.notes.substring(0, 1000);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Voucher Type Mapping
|
||||
// --------------------------------------------------------
|
||||
export function voucherTypeFromLexware(lexwareType: string): VoucherType {
|
||||
const map: Record<string, VoucherType> = {
|
||||
invoice: 'INVOICE',
|
||||
quotation: 'QUOTATION',
|
||||
orderconfirmation: 'ORDER_CONFIRMATION',
|
||||
creditnote: 'CREDIT_NOTE',
|
||||
};
|
||||
return map[lexwareType.toLowerCase()] || 'INVOICE';
|
||||
}
|
||||
|
||||
export function voucherTypeToLexwareEndpoint(type: VoucherType): string {
|
||||
const map: Record<VoucherType, string> = {
|
||||
INVOICE: 'invoices',
|
||||
QUOTATION: 'quotations',
|
||||
ORDER_CONFIRMATION: 'order-confirmations',
|
||||
CREDIT_NOTE: 'credit-notes',
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Deep Link Builder
|
||||
// --------------------------------------------------------
|
||||
export function buildLexwareDeepLink(
|
||||
voucherType: VoucherType,
|
||||
voucherId: string,
|
||||
): string {
|
||||
const typeMap: Record<VoucherType, string> = {
|
||||
INVOICE: 'invoices',
|
||||
QUOTATION: 'quotations',
|
||||
ORDER_CONFIRMATION: 'order-confirmations',
|
||||
CREDIT_NOTE: 'credit-notes',
|
||||
};
|
||||
const typePath = typeMap[voucherType];
|
||||
return `https://app.lexware.de/permalink/${typePath}/view/${voucherId}`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware Voucher Detail -> CRM Cache Daten
|
||||
// --------------------------------------------------------
|
||||
export function voucherDetailToCacheData(
|
||||
detail: LexwareVoucherDetail,
|
||||
voucherType: VoucherType,
|
||||
lexwareContactId: string,
|
||||
): {
|
||||
voucherType: VoucherType;
|
||||
voucherNumber: string | null;
|
||||
voucherDate: Date | null;
|
||||
voucherStatus: string | null;
|
||||
totalGrossAmount: number | null;
|
||||
totalNetAmount: number | null;
|
||||
totalTaxAmount: number | null;
|
||||
currency: string;
|
||||
title: string | null;
|
||||
lineItemsCount: number | null;
|
||||
lineItemsJson: string | null;
|
||||
lexwareContactId: string;
|
||||
lexwareDeepLink: string;
|
||||
} {
|
||||
const lineItems = detail.lineItems?.map((li) => ({
|
||||
name: li.name,
|
||||
quantity: li.quantity,
|
||||
unitName: li.unitName,
|
||||
unitPrice: li.unitPrice?.grossAmount,
|
||||
amount: li.lineItemAmount,
|
||||
}));
|
||||
|
||||
return {
|
||||
voucherType,
|
||||
voucherNumber: detail.voucherNumber || null,
|
||||
voucherDate: detail.voucherDate ? new Date(detail.voucherDate) : null,
|
||||
voucherStatus: detail.voucherStatus || null,
|
||||
totalGrossAmount: detail.totalPrice?.totalGrossAmount ?? null,
|
||||
totalNetAmount: detail.totalPrice?.totalNetAmount ?? null,
|
||||
totalTaxAmount: detail.totalPrice?.totalTaxAmount ?? null,
|
||||
currency: detail.totalPrice?.currency || 'EUR',
|
||||
title: detail.title || null,
|
||||
lineItemsCount: detail.lineItems?.length ?? null,
|
||||
lineItemsJson: lineItems ? JSON.stringify(lineItems) : null,
|
||||
lexwareContactId,
|
||||
lexwareDeepLink: buildLexwareDeepLink(voucherType, detail.id),
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// --------------------------------------------------------
|
||||
function getFirstEmail(lc: LexwareContact): string | undefined {
|
||||
const emails = lc.emailAddresses;
|
||||
if (!emails) return undefined;
|
||||
return (
|
||||
emails.business?.[0] ||
|
||||
emails.office?.[0] ||
|
||||
emails.private?.[0] ||
|
||||
emails.other?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
function getFirstPhone(lc: LexwareContact): string | undefined {
|
||||
const phones = lc.phoneNumbers;
|
||||
if (!phones) return undefined;
|
||||
return (
|
||||
phones.business?.[0] ||
|
||||
phones.office?.[0] ||
|
||||
phones.private?.[0] ||
|
||||
phones.other?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Lexware VoucherList Item -> Voucher Type
|
||||
// --------------------------------------------------------
|
||||
export function voucherListItemToType(
|
||||
item: LexwareVoucherListItem,
|
||||
): VoucherType {
|
||||
return voucherTypeFromLexware(item.voucherType);
|
||||
}
|
||||
64
packages/crm-service/src/lexware/utils/rate-limiter.ts
Normal file
64
packages/crm-service/src/lexware/utils/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// ============================================================
|
||||
// Token Bucket Rate Limiter fuer Lexware Office API
|
||||
// ============================================================
|
||||
// Max 2 Requests/Sekunde (Lexware Limit)
|
||||
// ============================================================
|
||||
|
||||
export class RateLimiter {
|
||||
private tokens: number;
|
||||
private lastRefill: number;
|
||||
private readonly maxTokens: number;
|
||||
private readonly refillRate: number; // Tokens pro Sekunde
|
||||
|
||||
constructor(maxTokens = 2, refillRate = 2) {
|
||||
this.maxTokens = maxTokens;
|
||||
this.refillRate = refillRate;
|
||||
this.tokens = maxTokens;
|
||||
this.lastRefill = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet bis ein Token verfuegbar ist, dann wird er verbraucht.
|
||||
* Blockiert maximal 5 Sekunden.
|
||||
*/
|
||||
async acquire(): Promise<void> {
|
||||
this.refill();
|
||||
|
||||
if (this.tokens >= 1) {
|
||||
this.tokens -= 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Berechne Wartezeit bis naechster Token verfuegbar
|
||||
const deficit = 1 - this.tokens;
|
||||
const waitMs = Math.ceil((deficit / this.refillRate) * 1000);
|
||||
|
||||
// Max 5 Sekunden warten
|
||||
const clampedWait = Math.min(waitMs, 5000);
|
||||
await new Promise((resolve) => setTimeout(resolve, clampedWait));
|
||||
|
||||
this.refill();
|
||||
this.tokens = Math.max(0, this.tokens - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuellt Tokens basierend auf vergangener Zeit auf.
|
||||
*/
|
||||
private refill(): void {
|
||||
const now = Date.now();
|
||||
const elapsed = (now - this.lastRefill) / 1000;
|
||||
this.tokens = Math.min(
|
||||
this.maxTokens,
|
||||
this.tokens + elapsed * this.refillRate,
|
||||
);
|
||||
this.lastRefill = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuelle Token-Anzahl (fuer Monitoring).
|
||||
*/
|
||||
getTokens(): number {
|
||||
this.refill();
|
||||
return this.tokens;
|
||||
}
|
||||
}
|
||||
|
|
@ -76,4 +76,13 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||
async del(key: string): Promise<void> {
|
||||
await this.client.del(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* SET key NX EX ttl - Distributed Lock.
|
||||
* Gibt true zurueck wenn Lock erworben, false wenn bereits gelockt.
|
||||
*/
|
||||
async setNx(key: string, value: string, ttlSeconds: number): Promise<boolean> {
|
||||
const result = await this.client.set(key, value, 'EX', ttlSeconds, 'NX');
|
||||
return result === 'OK';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue