mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 03:26: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_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem
|
||||||
- JWT_ISSUER=${JWT_ISSUER:-insight-platform}
|
- JWT_ISSUER=${JWT_ISSUER:-insight-platform}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59}
|
- 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:
|
volumes:
|
||||||
- ./packages/crm-service:/app
|
- ./packages/crm-service:/app
|
||||||
- /app/node_modules
|
- /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`*
|
*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/
|
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
|
tsconfig.json — Strict TypeScript
|
||||||
nest-cli.json — NestJS CLI Config
|
nest-cli.json — NestJS CLI Config
|
||||||
Dockerfile — Multi-Stage (base, deps, development, build, production)
|
Dockerfile — Multi-Stage (base, deps, development, build, production)
|
||||||
.dockerignore — Excludes
|
.dockerignore — Excludes
|
||||||
prisma/
|
prisma/
|
||||||
crm.schema.prisma — Eigenes Schema (app_crm) mit eigenem Client-Output
|
crm.schema.prisma — Eigenes Schema (app_crm) mit eigenem Client-Output
|
||||||
|
migrations/ — SQL-Migrationen
|
||||||
src/
|
src/
|
||||||
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger)
|
||||||
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter
|
app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule
|
||||||
config/ — Umgebungsvariablen-Validierung
|
config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*)
|
||||||
prisma/ — CrmPrismaService (eigener Client)
|
prisma/ — CrmPrismaService (eigener Client)
|
||||||
redis/ — RedisService (Token-Blocklist, Cache)
|
redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks)
|
||||||
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard
|
||||||
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter
|
||||||
companies/ — CRUD: Unternehmen (uebergeordnete Entity)
|
companies/ — CRUD: Unternehmen (mit Lexware ERP-Push bei Update)
|
||||||
contacts/ — CRUD: Kontakte (PERSON, ORGANIZATION) mit Company-Verknuepfung
|
contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update)
|
||||||
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK)
|
activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK)
|
||||||
pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update)
|
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)
|
### Datenbank-Modelle (app_crm Schema)
|
||||||
|
|
||||||
- **Company** — Unternehmen mit Branche, Adresse, Tags, Audit-Trail. Eltern-Entity fuer Contacts und Deals.
|
- **Company** — Unternehmen mit Lexware-Verknuepfung (lexwareContactId, lexwareContactVersion, lexwareSyncedAt)
|
||||||
- **Contact** — Kontakte (Person/Organisation) mit optionaler Company-Zuordnung (companyId, position)
|
- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung
|
||||||
- **Activity** — Aktivitaeten verknuepft mit Kontakten
|
- **Activity** — Aktivitaeten verknuepft mit Kontakten
|
||||||
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
- **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant
|
||||||
- **PipelineStage** — Stufen innerhalb einer Pipeline (Name, Farbe, Reihenfolge editierbar)
|
- **PipelineStage** — Stufen innerhalb einer Pipeline
|
||||||
- **Deal** — Vorgaenge mit Wert, Status, Pipeline/Stage/Contact/Company-Zuordnung
|
- **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
|
### Entity-Beziehungen
|
||||||
|
|
||||||
```
|
```
|
||||||
Company (1) --< (n) Contact — companyId (optional, SetNull bei Loeschung)
|
Company (1) --< (n) Contact — companyId (optional, SetNull)
|
||||||
Company (1) --< (n) Deal — companyId (optional, SetNull bei Loeschung)
|
Company (1) --< (n) Deal — companyId (optional, SetNull)
|
||||||
Contact (1) --< (n) Activity — contactId (Cascade bei Loeschung)
|
Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull)
|
||||||
Contact (1) --< (n) Deal — contactId (optional, SetNull bei Loeschung)
|
Contact (1) --< (n) Activity — contactId (Cascade)
|
||||||
Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade bei Loeschung)
|
Contact (1) --< (n) Deal — contactId (optional, SetNull)
|
||||||
Pipeline (1) --< (n) Deal — pipelineId (Cascade bei Loeschung)
|
Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull)
|
||||||
PipelineStage (1) --< (n) Deal — stageId
|
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
|
### API-Endpunkte
|
||||||
|
|
@ -66,73 +87,68 @@ PipelineStage (1) --< (n) Deal — stageId
|
||||||
| GET/POST | /api/v1/crm/pipelines | Liste / Erstellen |
|
| GET/POST | /api/v1/crm/pipelines | Liste / Erstellen |
|
||||||
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
|
| GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete |
|
||||||
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
| POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen |
|
||||||
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten (Name, Farbe, Reihenfolge) |
|
| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten |
|
||||||
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
| GET/POST | /api/v1/crm/deals | Liste / Erstellen |
|
||||||
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
| GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete |
|
||||||
| GET | /health | Health-Check (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-Integration
|
||||||
|
|
||||||
- `docker-compose.crm.yml` im Projekt-Root
|
- `docker-compose.crm.yml` im Projekt-Root
|
||||||
- Port: 3100
|
- Port: 3100
|
||||||
- Netzwerke: insight-web, insight-db, insight-cache
|
- Neue Env-Variablen: `LEXWARE_API_KEY`, `LEXWARE_API_URL`
|
||||||
- Traefik HTTP-Route: `Host(172.20.10.59) && PathPrefix(/api/v1/crm)` mit Priority 100
|
- Traefik HTTP + HTTPS Routing: `/api/v1/crm/*`
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
- JWT RS256 Validierung mit shared Public Key
|
- JWT RS256 Validierung mit shared Public Key
|
||||||
- Token-Revocation via Redis (blocked:{jti})
|
- Token-Revocation via Redis
|
||||||
- Multi-Tenancy: Alle Queries filtern nach tenantId
|
- Multi-Tenancy: Alle Queries filtern nach tenantId
|
||||||
- TenantGuard sichert mandantenbezogenen Zugriff
|
|
||||||
- Globaler ValidationPipe (whitelist + forbidNonWhitelisted)
|
- Globaler ValidationPipe (whitelist + forbidNonWhitelisted)
|
||||||
- Strict TypeScript, kein `any`
|
- Strict TypeScript, kein `any`
|
||||||
- 401 bei fehlendem/ungueltigem Token
|
|
||||||
|
|
||||||
### Deployment-Status
|
### Deployment-Status
|
||||||
|
|
||||||
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
|
**Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10**
|
||||||
|
|
||||||
- Container: insight-crm (Development-Mode)
|
- Prisma Migrationen:
|
||||||
- Prisma Migrationen angewendet:
|
- `20260310163211_init` — Initiales Schema
|
||||||
- `20260310163211_init` — Initiales Schema (Contact, Activity, Pipeline, PipelineStage, Deal)
|
- `20260310183117_add_companies` — Company-Entity
|
||||||
- `20260310183117_add_companies` — Company-Entity, Contact.companyId/position, Deal.companyId
|
- `20260310_add_lexware_integration` — Lexware Office Integration (AUSSTEHEND)
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Naechste Schritte
|
### Naechste Schritte
|
||||||
|
|
||||||
1. Frontend: Company-Modul (Seiten, Formulare, Sidebar-Link)
|
1. Migration `20260310_add_lexware_integration` auf Server anwenden
|
||||||
2. Frontend: Contact/Deal-Formulare um Company-Selektor erweitern
|
2. `LEXWARE_API_KEY` in `.env` auf Server setzen
|
||||||
3. Activity-Liste komplett laden (UI-Button "Alle anzeigen")
|
3. Container neu bauen und deployen
|
||||||
4. Kanban-Board fuer Vorgaenge (Drag & Drop Stage-Wechsel)
|
4. Lexware-Endpunkte auf Server testen
|
||||||
5. E2E-Tests schreiben
|
5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten
|
||||||
6. Production-Build testen (multi-stage Dockerfile)
|
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",
|
"version": "0.1.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/axios": "^3.1.3",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/config": "^3.2.0",
|
"@nestjs/config": "^3.2.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.0",
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
"@prisma/client": "^6.4.0",
|
"@prisma/client": "^6.4.0",
|
||||||
|
"axios": "^1.13.6",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|
@ -244,6 +247,7 @@
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
|
|
@ -1726,6 +1730,17 @@
|
||||||
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
|
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "10.4.9",
|
"version": "10.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
|
||||||
|
|
@ -2001,6 +2016,33 @@
|
||||||
"@nestjs/core": "^10.0.0"
|
"@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": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "10.2.3",
|
"version": "10.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||||
|
|
@ -2504,6 +2546,12 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
|
@ -3257,6 +3305,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
|
|
@ -4063,6 +4129,18 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
|
|
@ -4257,6 +4335,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4369,6 +4457,15 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
|
@ -4609,6 +4706,21 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -5353,6 +5465,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
|
@ -5430,6 +5562,22 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
|
@ -5792,6 +5940,21 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|
@ -7314,6 +7477,15 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
|
|
@ -8277,6 +8449,12 @@
|
||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,16 @@
|
||||||
"prisma:studio": "prisma studio --schema=prisma/crm.schema.prisma"
|
"prisma:studio": "prisma studio --schema=prisma/crm.schema.prisma"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/axios": "^3.1.3",
|
||||||
"@nestjs/common": "^10.4.0",
|
"@nestjs/common": "^10.4.0",
|
||||||
"@nestjs/config": "^3.2.0",
|
"@nestjs/config": "^3.2.0",
|
||||||
"@nestjs/core": "^10.4.0",
|
"@nestjs/core": "^10.4.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.0",
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
"@prisma/client": "^6.4.0",
|
"@prisma/client": "^6.4.0",
|
||||||
|
"axios": "^1.13.6",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|
@ -69,13 +72,19 @@
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ model Company {
|
||||||
|
|
||||||
isActive Boolean @default(true) @map("is_active")
|
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
|
// Audit-Trail
|
||||||
createdBy String @map("created_by") @db.Uuid
|
createdBy String @map("created_by") @db.Uuid
|
||||||
updatedBy String? @map("updated_by") @db.Uuid
|
updatedBy String? @map("updated_by") @db.Uuid
|
||||||
|
|
@ -53,9 +58,11 @@ model Company {
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relationen
|
// Relationen
|
||||||
contacts Contact[]
|
contacts Contact[]
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
|
lexwareVouchers LexwareVoucher[]
|
||||||
|
|
||||||
|
@@unique([tenantId, lexwareContactId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([tenantId, name])
|
@@index([tenantId, name])
|
||||||
@@index([tenantId, industry])
|
@@index([tenantId, industry])
|
||||||
|
|
@ -100,6 +107,11 @@ model Contact {
|
||||||
|
|
||||||
isActive Boolean @default(true) @map("is_active")
|
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)
|
// Audit-Trail (User-IDs aus platform_core)
|
||||||
createdBy String @map("created_by") @db.Uuid
|
createdBy String @map("created_by") @db.Uuid
|
||||||
updatedBy String? @map("updated_by") @db.Uuid
|
updatedBy String? @map("updated_by") @db.Uuid
|
||||||
|
|
@ -108,10 +120,12 @@ model Contact {
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relationen
|
// Relationen
|
||||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
|
lexwareVouchers LexwareVoucher[]
|
||||||
|
|
||||||
|
@@unique([tenantId, lexwareContactId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([tenantId, email])
|
@@index([tenantId, email])
|
||||||
@@index([tenantId, companyId])
|
@@index([tenantId, companyId])
|
||||||
|
|
@ -250,10 +264,11 @@ model Deal {
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
// Relationen
|
// Relationen
|
||||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||||
stage PipelineStage @relation(fields: [stageId], references: [id])
|
stage PipelineStage @relation(fields: [stageId], references: [id])
|
||||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
|
dealVouchers DealVoucher[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([tenantId, pipelineId])
|
@@index([tenantId, pipelineId])
|
||||||
|
|
@ -272,3 +287,86 @@ enum DealStatus {
|
||||||
|
|
||||||
@@schema("app_crm")
|
@@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 { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||||
import { validate } from './config/env.validation';
|
import { validate } from './config/env.validation';
|
||||||
import { CrmPrismaModule } from './prisma/crm-prisma.module';
|
import { CrmPrismaModule } from './prisma/crm-prisma.module';
|
||||||
|
|
@ -13,6 +14,7 @@ import { ActivitiesModule } from './activities/activities.module';
|
||||||
import { PipelinesModule } from './pipelines/pipelines.module';
|
import { PipelinesModule } from './pipelines/pipelines.module';
|
||||||
import { DealsModule } from './deals/deals.module';
|
import { DealsModule } from './deals/deals.module';
|
||||||
import { CompaniesModule } from './companies/companies.module';
|
import { CompaniesModule } from './companies/companies.module';
|
||||||
|
import { LexwareModule } from './lexware/lexware.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -20,6 +22,7 @@ import { CompaniesModule } from './companies/companies.module';
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate,
|
validate,
|
||||||
}),
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
CrmPrismaModule,
|
CrmPrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|
@ -29,6 +32,7 @@ import { CompaniesModule } from './companies/companies.module';
|
||||||
PipelinesModule,
|
PipelinesModule,
|
||||||
DealsModule,
|
DealsModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
LexwareModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||||
import { CompaniesController } from './companies.controller';
|
import { CompaniesController } from './companies.controller';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||||
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CrmPrismaModule],
|
imports: [CrmPrismaModule, LexwareModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService],
|
providers: [CompaniesService],
|
||||||
exports: [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 { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||||
|
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompaniesService {
|
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) {
|
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
||||||
return this.prisma.company.create({
|
return this.prisma.company.create({
|
||||||
|
|
@ -106,7 +112,9 @@ export class CompaniesService {
|
||||||
stage: { select: { id: true, name: true, color: true } },
|
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);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
return this.prisma.company.update({
|
const updated = await this.prisma.company.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...dto,
|
||||||
|
|
@ -135,6 +143,19 @@ export class CompaniesService {
|
||||||
_count: { select: { contacts: true, deals: true } },
|
_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) {
|
async remove(tenantId: string, id: string) {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,15 @@ export class EnvironmentVariables {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
CORS_ORIGINS?: string;
|
CORS_ORIGINS?: string;
|
||||||
|
|
||||||
|
// Lexware Office Integration (optional)
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
LEXWARE_API_KEY?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
LEXWARE_API_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validate(
|
export function validate(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ContactsController } from './contacts.controller';
|
import { ContactsController } from './contacts.controller';
|
||||||
import { ContactsService } from './contacts.service';
|
import { ContactsService } from './contacts.service';
|
||||||
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [LexwareModule],
|
||||||
controllers: [ContactsController],
|
controllers: [ContactsController],
|
||||||
providers: [ContactsService],
|
providers: [ContactsService],
|
||||||
exports: [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 { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
import { CreateContactDto } from './dto/create-contact.dto';
|
import { CreateContactDto } from './dto/create-contact.dto';
|
||||||
import { UpdateContactDto } from './dto/update-contact.dto';
|
import { UpdateContactDto } from './dto/update-contact.dto';
|
||||||
import { QueryContactsDto } from './dto/query-contacts.dto';
|
import { QueryContactsDto } from './dto/query-contacts.dto';
|
||||||
|
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
import { Prisma } from '.prisma/crm-client';
|
import { Prisma } from '.prisma/crm-client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContactsService {
|
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) {
|
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||||
return this.prisma.contact.create({
|
return this.prisma.contact.create({
|
||||||
|
|
@ -107,13 +113,26 @@ export class ContactsService {
|
||||||
) {
|
) {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
|
|
||||||
return this.prisma.contact.update({
|
const updated = await this.prisma.contact.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...dto,
|
||||||
updatedBy: userId,
|
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) {
|
async remove(tenantId: string, id: string) {
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,24 @@ export class DealsService {
|
||||||
stage: true,
|
stage: true,
|
||||||
contact: true,
|
contact: true,
|
||||||
company: 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 { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
import { Public } from '../common/decorators/public.decorator';
|
import { Public } from '../common/decorators/public.decorator';
|
||||||
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
import { RedisService } from '../redis/redis.service';
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import { LexwareClientService } from '../lexware/lexware-client.service';
|
||||||
|
|
||||||
interface HealthResponse {
|
interface HealthResponse {
|
||||||
status: 'ok' | 'error';
|
status: 'ok' | 'error';
|
||||||
|
|
@ -12,6 +13,7 @@ interface HealthResponse {
|
||||||
services: {
|
services: {
|
||||||
database: 'up' | 'down';
|
database: 'up' | 'down';
|
||||||
redis: 'up' | 'down';
|
redis: 'up' | 'down';
|
||||||
|
lexware: 'up' | 'down' | 'unconfigured';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,35 +23,39 @@ export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
|
@Optional() @Inject(LexwareClientService)
|
||||||
|
private readonly lexwareClient?: LexwareClientService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
@ApiOperation({ summary: 'Health-Check fuer CRM-Service' })
|
||||||
async check(): Promise<HealthResponse> {
|
async check(): Promise<HealthResponse> {
|
||||||
const [dbStatus, redisStatus] = await Promise.allSettled([
|
const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([
|
||||||
this.checkDatabase(),
|
this.checkDatabase(),
|
||||||
this.checkRedis(),
|
this.checkRedis(),
|
||||||
|
this.checkLexware(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allUp =
|
const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value;
|
||||||
dbStatus.status === 'fulfilled' &&
|
const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value;
|
||||||
dbStatus.value &&
|
const lexwareResult: 'up' | 'down' | 'unconfigured' =
|
||||||
redisStatus.status === 'fulfilled' &&
|
lexwareStatus.status === 'fulfilled'
|
||||||
redisStatus.value;
|
? lexwareStatus.value
|
||||||
|
: 'down';
|
||||||
|
|
||||||
|
// Lexware "unconfigured" ist kein Fehler
|
||||||
|
const allUp = dbOk && redisOk && lexwareResult !== 'down';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: allUp ? 'ok' : 'error',
|
status: allUp ? 'ok' : 'error',
|
||||||
service: 'crm-service',
|
service: 'crm-service',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
version: '0.1.0',
|
version: '0.2.0',
|
||||||
services: {
|
services: {
|
||||||
database:
|
database: dbOk ? 'up' : 'down',
|
||||||
dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down',
|
redis: redisOk ? 'up' : 'down',
|
||||||
redis:
|
lexware: lexwareResult,
|
||||||
redisStatus.status === 'fulfilled' && redisStatus.value
|
|
||||||
? 'up'
|
|
||||||
: 'down',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -71,4 +77,9 @@ export class HealthController {
|
||||||
return false;
|
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 { Module } from '@nestjs/common';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [LexwareModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
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> {
|
async del(key: string): Promise<void> {
|
||||||
await this.client.del(key);
|
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