diff --git a/docker-compose.crm.yml b/docker-compose.crm.yml index 28210f2..a6dd626 100644 --- a/docker-compose.crm.yml +++ b/docker-compose.crm.yml @@ -24,6 +24,9 @@ services: - JWT_PUBLIC_KEY_PATH=/app/keys/jwt-public.pem - JWT_ISSUER=${JWT_ISSUER:-insight-platform} - CORS_ORIGINS=${CORS_ORIGINS:-http://172.20.10.59} + # Lexware Office Integration (optional) + - LEXWARE_API_KEY=${LEXWARE_API_KEY:-} + - LEXWARE_API_URL=${LEXWARE_API_URL:-https://api.lexware.io} volumes: - ./packages/crm-service:/app - /app/node_modules diff --git a/docs/INSIGHT-CRM.md b/docs/INSIGHT-CRM.md index 0497c56..a89aab7 100644 --- a/docs/INSIGHT-CRM.md +++ b/docs/INSIGHT-CRM.md @@ -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`* diff --git a/packages/crm-service/Summarize.md b/packages/crm-service/Summarize.md index 1384607..59877d6 100644 --- a/packages/crm-service/Summarize.md +++ b/packages/crm-service/Summarize.md @@ -10,47 +10,68 @@ Der CRM-Service als eigenstaendiges NestJS-Package unter `packages/crm-service/` ``` packages/crm-service/ - package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis) + package.json — Dependencies (NestJS 10, Prisma, Passport, ioredis, @nestjs/axios, @nestjs/schedule) tsconfig.json — Strict TypeScript nest-cli.json — NestJS CLI Config Dockerfile — Multi-Stage (base, deps, development, build, production) .dockerignore — Excludes prisma/ crm.schema.prisma — Eigenes Schema (app_crm) mit eigenem Client-Output + migrations/ — SQL-Migrationen src/ main.ts — Bootstrap (Port 3100, Prefix: api/v1/crm, Swagger) - app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter - config/ — Umgebungsvariablen-Validierung + app.module.ts — Root Module mit globalem JwtAuthGuard + ExceptionFilter + ScheduleModule + config/ — Umgebungsvariablen-Validierung (inkl. LEXWARE_*) prisma/ — CrmPrismaService (eigener Client) - redis/ — RedisService (Token-Blocklist, Cache) + redis/ — RedisService (Token-Blocklist, Cache, Distributed Locks) auth/ — JWT Strategy (RS256), JwtAuthGuard, RolesGuard, TenantGuard common/ — Decorators (@Public, @Roles, @CurrentUser), Pagination, ExceptionFilter - companies/ — CRUD: Unternehmen (uebergeordnete Entity) - contacts/ — CRUD: Kontakte (PERSON, ORGANIZATION) mit Company-Verknuepfung + companies/ — CRUD: Unternehmen (mit Lexware ERP-Push bei Update) + contacts/ — CRUD: Kontakte (mit Lexware ERP-Push bei Update) activities/ — CRUD: Aktivitaeten (NOTE, CALL, EMAIL, MEETING, TASK) pipelines/ — CRUD: Sales-Pipelines mit Stages (inkl. Stage-Update) - deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company-Zuordnung + deals/ — CRUD: Vorgaenge mit Pipeline/Stage/Contact/Company + DealVouchers + health/ — Health-Check (DB, Redis, Lexware) + lexware/ — Lexware Office Integration (NEU) + lexware.module.ts — Feature Module (HttpModule + ScheduleModule) + lexware-client.service.ts — Rate-limitierter HTTP Client (Token Bucket, 2 req/s) + lexware-contacts.service.ts — Kontakt-Suche, Link, Import, Push, Sync + lexware-vouchers.service.ts — Beleg-Abruf, Cache, Deal-Verknuepfung + lexware-sync.service.ts — Cron-Jobs (Beleg-Sync 4h, ERP-Push 30min) + lexware-contacts.controller.ts — REST Endpoints Kontakt-Operationen + lexware-vouchers.controller.ts — REST Endpoints Beleg-Operationen + dto/ — Validierungs-DTOs + interfaces/ — TypeScript Interfaces fuer Lexware API + utils/ + rate-limiter.ts — Token Bucket (max 2, 2/s Refill) + lexware-mapper.ts — Bidirektionales Mapping CRM <-> Lexware ``` ### Datenbank-Modelle (app_crm Schema) -- **Company** — Unternehmen mit Branche, Adresse, Tags, Audit-Trail. Eltern-Entity fuer Contacts und Deals. -- **Contact** — Kontakte (Person/Organisation) mit optionaler Company-Zuordnung (companyId, position) +- **Company** — Unternehmen mit Lexware-Verknuepfung (lexwareContactId, lexwareContactVersion, lexwareSyncedAt) +- **Contact** — Kontakte mit optionaler Lexware-Verknuepfung - **Activity** — Aktivitaeten verknuepft mit Kontakten - **Pipeline** — Konfigurierbare Sales-Pipelines pro Tenant -- **PipelineStage** — Stufen innerhalb einer Pipeline (Name, Farbe, Reihenfolge editierbar) -- **Deal** — Vorgaenge mit Wert, Status, Pipeline/Stage/Contact/Company-Zuordnung +- **PipelineStage** — Stufen innerhalb einer Pipeline +- **Deal** — Vorgaenge mit dealVouchers-Relation zu Lexware-Belegen +- **LexwareVoucher** (NEU) — Gecachte Belege aus Lexware Office (Angebote, Auftraege, Rechnungen, Gutschriften) +- **DealVoucher** (NEU) — Join-Table Deal <-> Beleg (m:n mit Audit-Trail) ### Entity-Beziehungen ``` -Company (1) --< (n) Contact — companyId (optional, SetNull bei Loeschung) -Company (1) --< (n) Deal — companyId (optional, SetNull bei Loeschung) -Contact (1) --< (n) Activity — contactId (Cascade bei Loeschung) -Contact (1) --< (n) Deal — contactId (optional, SetNull bei Loeschung) -Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade bei Loeschung) -Pipeline (1) --< (n) Deal — pipelineId (Cascade bei Loeschung) -PipelineStage (1) --< (n) Deal — stageId +Company (1) --< (n) Contact — companyId (optional, SetNull) +Company (1) --< (n) Deal — companyId (optional, SetNull) +Company (1) --< (n) LexwareVoucher — companyId (optional, SetNull) +Contact (1) --< (n) Activity — contactId (Cascade) +Contact (1) --< (n) Deal — contactId (optional, SetNull) +Contact (1) --< (n) LexwareVoucher — contactId (optional, SetNull) +Pipeline (1) --< (n) PipelineStage — pipelineId (Cascade) +Pipeline (1) --< (n) Deal — pipelineId (Cascade) +PipelineStage (1) --< (n) Deal — stageId +Deal (1) --< (n) DealVoucher — dealId (Cascade) +LexwareVoucher (1) --< (n) DealVoucher — voucherId (Cascade) ``` ### API-Endpunkte @@ -66,73 +87,68 @@ PipelineStage (1) --< (n) Deal — stageId | GET/POST | /api/v1/crm/pipelines | Liste / Erstellen | | GET/PATCH/DELETE | /api/v1/crm/pipelines/:id | Detail / Update / Delete | | POST/DELETE | /api/v1/crm/pipelines/:id/stages | Stage hinzufuegen/entfernen | -| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten (Name, Farbe, Reihenfolge) | +| PATCH | /api/v1/crm/pipelines/:id/stages/:stageId | Stage bearbeiten | | GET/POST | /api/v1/crm/deals | Liste / Erstellen | | GET/PATCH/DELETE | /api/v1/crm/deals/:id | Detail / Update / Delete | -| GET | /health | Health-Check (public) | +| GET | /health | Health-Check (DB, Redis, Lexware) | +| **Lexware Kontakte** | | | +| GET | /api/v1/crm/lexware/contacts/search | Lexware-Kontakte suchen | +| POST | /api/v1/crm/lexware/contacts/link-company | Company verknuepfen | +| POST | /api/v1/crm/lexware/contacts/link-contact | Contact verknuepfen | +| DELETE | /api/v1/crm/lexware/contacts/unlink-company/:id | Verknuepfung loesen | +| DELETE | /api/v1/crm/lexware/contacts/unlink-contact/:id | Verknuepfung loesen | +| POST | /api/v1/crm/lexware/contacts/import-company | Company aus Lexware importieren | +| POST | /api/v1/crm/lexware/contacts/import-contact | Contact aus Lexware importieren | +| POST | /api/v1/crm/lexware/contacts/push/:type/:id | CRM -> Lexware pushen | +| POST | /api/v1/crm/lexware/contacts/sync/:type/:id | Lexware -> CRM synchronisieren | +| **Lexware Belege** | | | +| GET | /api/v1/crm/lexware/vouchers/company/:id | Belege fuer Unternehmen | +| GET | /api/v1/crm/lexware/vouchers/contact/:id | Belege fuer Kontakt | +| GET | /api/v1/crm/lexware/vouchers/deal/:id | Belege fuer Vorgang | +| POST | /api/v1/crm/lexware/vouchers/deal/:id/link | Beleg mit Vorgang verknuepfen | +| DELETE | /api/v1/crm/lexware/vouchers/deal/:id/unlink/:vid | Verknuepfung loesen | +| POST | /api/v1/crm/lexware/vouchers/refresh/company/:id | Cache aktualisieren | +| POST | /api/v1/crm/lexware/vouchers/refresh/contact/:id | Cache aktualisieren | + +### Lexware Office Integration — Details + +- **Rate Limiter**: In-Memory Token Bucket, 2 Requests/Sekunde (Lexware API Limit) +- **Beleg-Caching**: PostgreSQL-Tabelle `lexware_vouchers`, alle 4h Cron-Refresh + manueller Refresh +- **ERP-Push**: Companies/Contacts mit Tag "ERP" werden automatisch (30min Cron) + bei Update nach Lexware gepusht +- **Distributed Locks**: Redis SET NX EX verhindert Doppelausfuehrung von Cron-Jobs +- **Optimistic Locking**: lexwareContactVersion fuer sichere Updates +- **Graceful Degradation**: Ohne LEXWARE_API_KEY → Modul deaktiviert, Health = "unconfigured" ### Docker-Integration - `docker-compose.crm.yml` im Projekt-Root - Port: 3100 -- Netzwerke: insight-web, insight-db, insight-cache -- Traefik HTTP-Route: `Host(172.20.10.59) && PathPrefix(/api/v1/crm)` mit Priority 100 -- Traefik HTTPS-Route: `crm-secure` mit `entrypoints=websecure`, `tls=true`, Priority 100 -- JWT Public Key als Read-Only Volume (.keys/jwt-public.pem) -- Direkte PostgreSQL-Verbindung (PgBouncer unterstuetzt kein search_path fuer Schema-Auswahl) +- Neue Env-Variablen: `LEXWARE_API_KEY`, `LEXWARE_API_URL` +- Traefik HTTP + HTTPS Routing: `/api/v1/crm/*` ### Sicherheit - JWT RS256 Validierung mit shared Public Key -- Token-Revocation via Redis (blocked:{jti}) +- Token-Revocation via Redis - Multi-Tenancy: Alle Queries filtern nach tenantId -- TenantGuard sichert mandantenbezogenen Zugriff - Globaler ValidationPipe (whitelist + forbidNonWhitelisted) - Strict TypeScript, kein `any` -- 401 bei fehlendem/ungueltigem Token ### Deployment-Status **Erfolgreich deployed auf insight-dev-01 (172.20.10.59) am 2026-03-10** -- Container: insight-crm (Development-Mode) -- Prisma Migrationen angewendet: - - `20260310163211_init` — Initiales Schema (Contact, Activity, Pipeline, PipelineStage, Deal) - - `20260310183117_add_companies` — Company-Entity, Contact.companyId/position, Deal.companyId -- Alle API-Endpunkte getestet und funktionsfaehig -- Traefik-Routing aktiv (HTTP + HTTPS): http(s)://172.20.10.59/api/v1/crm/* -- Swagger-Docs: http://172.20.10.59/api/v1/crm/docs/ - -### Getestete Endpunkte - -| Test | Ergebnis | -|------|----------| -| POST /companies (Erstellen mit allen Feldern) | 200 OK, UUID + _count korrekt | -| GET /companies (Liste) | 200 OK, pagination + _count korrekt | -| GET /companies/:id (Detail) | 200 OK, contacts[] + deals[] + _count | -| PATCH /companies/:id (Update) | 200 OK, updatedBy + tags korrekt | -| GET /companies?search=Xinion | 200 OK, Suche funktioniert | -| POST /contacts (mit companyId + position) | 201 Created, Company-Verknuepfung korrekt | -| GET /contacts/:id (mit Company) | 200 OK, company-Objekt enthalten | -| POST /activities (Notiz) | 201 Created, contactId verknuepft | -| POST /pipelines (mit 4 Stages) | 201 Created, Stages korrekt | -| PATCH /pipelines/:id/stages/:stageId | 200 OK, Stage-Update korrekt | -| POST /deals (mit companyId + contactId) | 200 OK, Company + Contact verknuepft | -| GET /deals/:id (mit Company) | 200 OK, company + pipeline.stages enthalten | -| GET /deals?companyId=... | 200 OK, Filter nach Company funktioniert | -| PATCH /deals/:id (WON) | 200 OK, closedAt automatisch gesetzt | -| GET /contacts ohne Token | 401 Unauthorized | -| Validierung (falsche Felder) | 400 Bad Request, Details korrekt | - -### Bekannte Einschraenkungen - -- PgBouncer kann nicht genutzt werden (search_path nicht kompatibel mit transaction pooling) +- Prisma Migrationen: + - `20260310163211_init` — Initiales Schema + - `20260310183117_add_companies` — Company-Entity + - `20260310_add_lexware_integration` — Lexware Office Integration (AUSSTEHEND) ### Naechste Schritte -1. Frontend: Company-Modul (Seiten, Formulare, Sidebar-Link) -2. Frontend: Contact/Deal-Formulare um Company-Selektor erweitern -3. Activity-Liste komplett laden (UI-Button "Alle anzeigen") -4. Kanban-Board fuer Vorgaenge (Drag & Drop Stage-Wechsel) -5. E2E-Tests schreiben -6. Production-Build testen (multi-stage Dockerfile) +1. Migration `20260310_add_lexware_integration` auf Server anwenden +2. `LEXWARE_API_KEY` in `.env` auf Server setzen +3. Container neu bauen und deployen +4. Lexware-Endpunkte auf Server testen +5. Frontend: Lexware-Integration in Company/Contact/Deal-Detail-Seiten +6. Activity-Liste komplett laden (UI-Button "Alle anzeigen") +7. Kanban-Board fuer Vorgaenge diff --git a/packages/crm-service/package-lock.json b/packages/crm-service/package-lock.json index 92fcb87..5e8a5a0 100644 --- a/packages/crm-service/package-lock.json +++ b/packages/crm-service/package-lock.json @@ -9,13 +9,16 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.4.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.0", + "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^7.4.0", "@prisma/client": "^6.4.0", + "axios": "^1.13.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", @@ -244,6 +247,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1726,6 +1730,17 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@nestjs/axios": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", + "integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -2001,6 +2016,33 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", + "integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==", + "license": "MIT", + "dependencies": { + "cron": "3.2.1", + "uuid": "11.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -2504,6 +2546,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3257,6 +3305,24 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -4063,6 +4129,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4257,6 +4335,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cron": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz", + "integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.5.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4369,6 +4457,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -4609,6 +4706,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5353,6 +5465,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5430,6 +5562,22 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5792,6 +5940,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -7314,6 +7477,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -8277,6 +8449,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/packages/crm-service/package.json b/packages/crm-service/package.json index 45aefca..d246715 100644 --- a/packages/crm-service/package.json +++ b/packages/crm-service/package.json @@ -25,13 +25,16 @@ "prisma:studio": "prisma studio --schema=prisma/crm.schema.prisma" }, "dependencies": { + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.4.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.0", + "@nestjs/schedule": "^4.1.2", "@nestjs/swagger": "^7.4.0", "@prisma/client": "^6.4.0", + "axios": "^1.13.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", @@ -69,13 +72,19 @@ "typescript": "^5.6.0" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { diff --git a/packages/crm-service/prisma/crm.schema.prisma b/packages/crm-service/prisma/crm.schema.prisma index eae01f3..8bb0e82 100644 --- a/packages/crm-service/prisma/crm.schema.prisma +++ b/packages/crm-service/prisma/crm.schema.prisma @@ -45,6 +45,11 @@ model Company { isActive Boolean @default(true) @map("is_active") + // Lexware Office Integration + lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36) + lexwareContactVersion Int? @map("lexware_contact_version") + lexwareSyncedAt DateTime? @map("lexware_synced_at") + // Audit-Trail createdBy String @map("created_by") @db.Uuid updatedBy String? @map("updated_by") @db.Uuid @@ -53,9 +58,11 @@ model Company { updatedAt DateTime @updatedAt @map("updated_at") // Relationen - contacts Contact[] - deals Deal[] + contacts Contact[] + deals Deal[] + lexwareVouchers LexwareVoucher[] + @@unique([tenantId, lexwareContactId]) @@index([tenantId]) @@index([tenantId, name]) @@index([tenantId, industry]) @@ -100,6 +107,11 @@ model Contact { isActive Boolean @default(true) @map("is_active") + // Lexware Office Integration + lexwareContactId String? @map("lexware_contact_id") @db.VarChar(36) + lexwareContactVersion Int? @map("lexware_contact_version") + lexwareSyncedAt DateTime? @map("lexware_synced_at") + // Audit-Trail (User-IDs aus platform_core) createdBy String @map("created_by") @db.Uuid updatedBy String? @map("updated_by") @db.Uuid @@ -108,10 +120,12 @@ model Contact { updatedAt DateTime @updatedAt @map("updated_at") // Relationen - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) - activities Activity[] - deals Deal[] + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + activities Activity[] + deals Deal[] + lexwareVouchers LexwareVoucher[] + @@unique([tenantId, lexwareContactId]) @@index([tenantId]) @@index([tenantId, email]) @@index([tenantId, companyId]) @@ -250,10 +264,11 @@ model Deal { updatedAt DateTime @updatedAt @map("updated_at") // Relationen - pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) - stage PipelineStage @relation(fields: [stageId], references: [id]) - contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) - company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) + stage PipelineStage @relation(fields: [stageId], references: [id]) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealVouchers DealVoucher[] @@index([tenantId]) @@index([tenantId, pipelineId]) @@ -272,3 +287,86 @@ enum DealStatus { @@schema("app_crm") } + +// -------------------------------------------------------- +// Lexware Office Integration - Voucher Types +// -------------------------------------------------------- +enum VoucherType { + QUOTATION + ORDER_CONFIRMATION + INVOICE + CREDIT_NOTE + + @@schema("app_crm") +} + +// -------------------------------------------------------- +// LexwareVoucher - Gecachte Belege aus Lexware Office +// -------------------------------------------------------- +model LexwareVoucher { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + lexwareVoucherId String @map("lexware_voucher_id") @db.VarChar(36) + voucherType VoucherType @map("voucher_type") + + voucherNumber String? @map("voucher_number") @db.VarChar(100) + voucherDate DateTime? @map("voucher_date") + voucherStatus String? @map("voucher_status") @db.VarChar(50) + + totalGrossAmount Decimal? @map("total_gross_amount") @db.Decimal(15, 2) + totalNetAmount Decimal? @map("total_net_amount") @db.Decimal(15, 2) + totalTaxAmount Decimal? @map("total_tax_amount") @db.Decimal(15, 2) + currency String @default("EUR") @db.VarChar(3) + + title String? @db.VarChar(500) + lineItemsCount Int? @map("line_items_count") + lineItemsJson String? @map("line_items_json") @db.Text + + // Verknuepfung zu Lexware-Kontakt und CRM-Entitaeten + lexwareContactId String @map("lexware_contact_id") @db.VarChar(36) + companyId String? @map("company_id") @db.Uuid + contactId String? @map("contact_id") @db.Uuid + + lexwareDeepLink String? @map("lexware_deep_link") @db.VarChar(500) + + fetchedAt DateTime @default(now()) @map("fetched_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relationen + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + deals DealVoucher[] + + @@unique([tenantId, lexwareVoucherId]) + @@index([tenantId]) + @@index([tenantId, companyId]) + @@index([tenantId, contactId]) + @@index([tenantId, lexwareContactId]) + @@index([tenantId, voucherType]) + @@map("lexware_vouchers") + @@schema("app_crm") +} + +// -------------------------------------------------------- +// DealVoucher - Verknuepfung Deal <-> Lexware-Beleg (m:n) +// -------------------------------------------------------- +model DealVoucher { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + dealId String @map("deal_id") @db.Uuid + voucherId String @map("voucher_id") @db.Uuid + linkedBy String @map("linked_by") @db.Uuid + linkedAt DateTime @default(now()) @map("linked_at") + + // Relationen + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + voucher LexwareVoucher @relation(fields: [voucherId], references: [id], onDelete: Cascade) + + @@unique([dealId, voucherId]) + @@index([tenantId]) + @@index([tenantId, dealId]) + @@index([tenantId, voucherId]) + @@map("deal_vouchers") + @@schema("app_crm") +} diff --git a/packages/crm-service/prisma/migrations/20260310_add_lexware_integration/migration.sql b/packages/crm-service/prisma/migrations/20260310_add_lexware_integration/migration.sql new file mode 100644 index 0000000..49bce6d --- /dev/null +++ b/packages/crm-service/prisma/migrations/20260310_add_lexware_integration/migration.sql @@ -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; diff --git a/packages/crm-service/src/app.module.ts b/packages/crm-service/src/app.module.ts index b57d756..3709055 100644 --- a/packages/crm-service/src/app.module.ts +++ b/packages/crm-service/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { APP_GUARD, APP_FILTER } from '@nestjs/core'; import { validate } from './config/env.validation'; import { CrmPrismaModule } from './prisma/crm-prisma.module'; @@ -13,6 +14,7 @@ import { ActivitiesModule } from './activities/activities.module'; import { PipelinesModule } from './pipelines/pipelines.module'; import { DealsModule } from './deals/deals.module'; import { CompaniesModule } from './companies/companies.module'; +import { LexwareModule } from './lexware/lexware.module'; @Module({ imports: [ @@ -20,6 +22,7 @@ import { CompaniesModule } from './companies/companies.module'; isGlobal: true, validate, }), + ScheduleModule.forRoot(), CrmPrismaModule, RedisModule, AuthModule, @@ -29,6 +32,7 @@ import { CompaniesModule } from './companies/companies.module'; PipelinesModule, DealsModule, CompaniesModule, + LexwareModule, ], providers: [ { diff --git a/packages/crm-service/src/companies/companies.module.ts b/packages/crm-service/src/companies/companies.module.ts index aef5e48..ac8ace3 100644 --- a/packages/crm-service/src/companies/companies.module.ts +++ b/packages/crm-service/src/companies/companies.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { CompaniesController } from './companies.controller'; import { CompaniesService } from './companies.service'; import { CrmPrismaModule } from '../prisma/crm-prisma.module'; +import { LexwareModule } from '../lexware/lexware.module'; @Module({ - imports: [CrmPrismaModule], + imports: [CrmPrismaModule, LexwareModule], controllers: [CompaniesController], providers: [CompaniesService], exports: [CompaniesService], diff --git a/packages/crm-service/src/companies/companies.service.ts b/packages/crm-service/src/companies/companies.service.ts index faddc68..f8fcb6f 100644 --- a/packages/crm-service/src/companies/companies.service.ts +++ b/packages/crm-service/src/companies/companies.service.ts @@ -1,13 +1,19 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CreateCompanyDto } from './dto/create-company.dto'; import { UpdateCompanyDto } from './dto/update-company.dto'; import { QueryCompaniesDto } from './dto/query-companies.dto'; +import { LexwareContactsService } from '../lexware/lexware-contacts.service'; import { Prisma } from '.prisma/crm-client'; @Injectable() export class CompaniesService { - constructor(private readonly prisma: CrmPrismaService) {} + private readonly logger = new Logger(CompaniesService.name); + + constructor( + private readonly prisma: CrmPrismaService, + private readonly lexwareContacts: LexwareContactsService, + ) {} async create(tenantId: string, userId: string, dto: CreateCompanyDto) { return this.prisma.company.create({ @@ -106,7 +112,9 @@ export class CompaniesService { stage: { select: { id: true, name: true, color: true } }, }, }, - _count: { select: { contacts: true, deals: true } }, + _count: { + select: { contacts: true, deals: true, lexwareVouchers: true }, + }, }, }); @@ -125,7 +133,7 @@ export class CompaniesService { ) { await this.findOne(tenantId, id); - return this.prisma.company.update({ + const updated = await this.prisma.company.update({ where: { id }, data: { ...dto, @@ -135,6 +143,19 @@ export class CompaniesService { _count: { select: { contacts: true, deals: true } }, }, }); + + // ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push + if (updated.lexwareContactId && updated.tags.includes('ERP')) { + this.lexwareContacts + .pushCompanyToLexware(tenantId, id) + .catch((err: Error) => + this.logger.warn( + `ERP-Push nach Update fehlgeschlagen fuer Company "${updated.name}": ${err.message}`, + ), + ); + } + + return updated; } async remove(tenantId: string, id: string) { diff --git a/packages/crm-service/src/config/env.validation.ts b/packages/crm-service/src/config/env.validation.ts index 1657404..2455c19 100644 --- a/packages/crm-service/src/config/env.validation.ts +++ b/packages/crm-service/src/config/env.validation.ts @@ -39,6 +39,15 @@ export class EnvironmentVariables { @IsString() @IsOptional() CORS_ORIGINS?: string; + + // Lexware Office Integration (optional) + @IsString() + @IsOptional() + LEXWARE_API_KEY?: string; + + @IsString() + @IsOptional() + LEXWARE_API_URL?: string; } export function validate( diff --git a/packages/crm-service/src/contacts/contacts.module.ts b/packages/crm-service/src/contacts/contacts.module.ts index ef19ed0..f8ae6fc 100644 --- a/packages/crm-service/src/contacts/contacts.module.ts +++ b/packages/crm-service/src/contacts/contacts.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ContactsController } from './contacts.controller'; import { ContactsService } from './contacts.service'; +import { LexwareModule } from '../lexware/lexware.module'; @Module({ + imports: [LexwareModule], controllers: [ContactsController], providers: [ContactsService], exports: [ContactsService], diff --git a/packages/crm-service/src/contacts/contacts.service.ts b/packages/crm-service/src/contacts/contacts.service.ts index e45e5d9..95712bd 100644 --- a/packages/crm-service/src/contacts/contacts.service.ts +++ b/packages/crm-service/src/contacts/contacts.service.ts @@ -1,13 +1,19 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { CreateContactDto } from './dto/create-contact.dto'; import { UpdateContactDto } from './dto/update-contact.dto'; import { QueryContactsDto } from './dto/query-contacts.dto'; +import { LexwareContactsService } from '../lexware/lexware-contacts.service'; import { Prisma } from '.prisma/crm-client'; @Injectable() export class ContactsService { - constructor(private readonly prisma: CrmPrismaService) {} + private readonly logger = new Logger(ContactsService.name); + + constructor( + private readonly prisma: CrmPrismaService, + private readonly lexwareContacts: LexwareContactsService, + ) {} async create(tenantId: string, userId: string, dto: CreateContactDto) { return this.prisma.contact.create({ @@ -107,13 +113,26 @@ export class ContactsService { ) { await this.findOne(tenantId, id); - return this.prisma.contact.update({ + const updated = await this.prisma.contact.update({ where: { id }, data: { ...dto, updatedBy: userId, }, }); + + // ERP-Push: Wenn Lexware verknuepft UND "ERP"-Tag gesetzt → async Push + if (updated.lexwareContactId && updated.tags.includes('ERP')) { + this.lexwareContacts + .pushContactToLexware(tenantId, id) + .catch((err: Error) => + this.logger.warn( + `ERP-Push nach Update fehlgeschlagen fuer Contact "${updated.firstName} ${updated.lastName}": ${err.message}`, + ), + ); + } + + return updated; } async remove(tenantId: string, id: string) { diff --git a/packages/crm-service/src/deals/deals.service.ts b/packages/crm-service/src/deals/deals.service.ts index 77647b4..6b3dd8b 100644 --- a/packages/crm-service/src/deals/deals.service.ts +++ b/packages/crm-service/src/deals/deals.service.ts @@ -158,6 +158,24 @@ export class DealsService { stage: true, contact: true, company: true, + dealVouchers: { + include: { + voucher: { + select: { + id: true, + voucherType: true, + voucherNumber: true, + voucherDate: true, + voucherStatus: true, + totalGrossAmount: true, + currency: true, + title: true, + lexwareDeepLink: true, + }, + }, + }, + orderBy: { linkedAt: 'desc' }, + }, }, }); diff --git a/packages/crm-service/src/health/health.controller.ts b/packages/crm-service/src/health/health.controller.ts index 94d9a65..daa1d0e 100644 --- a/packages/crm-service/src/health/health.controller.ts +++ b/packages/crm-service/src/health/health.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Inject, Optional } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { Public } from '../common/decorators/public.decorator'; import { CrmPrismaService } from '../prisma/crm-prisma.service'; import { RedisService } from '../redis/redis.service'; +import { LexwareClientService } from '../lexware/lexware-client.service'; interface HealthResponse { status: 'ok' | 'error'; @@ -12,6 +13,7 @@ interface HealthResponse { services: { database: 'up' | 'down'; redis: 'up' | 'down'; + lexware: 'up' | 'down' | 'unconfigured'; }; } @@ -21,35 +23,39 @@ export class HealthController { constructor( private readonly prisma: CrmPrismaService, private readonly redis: RedisService, + @Optional() @Inject(LexwareClientService) + private readonly lexwareClient?: LexwareClientService, ) {} @Get() @Public() @ApiOperation({ summary: 'Health-Check fuer CRM-Service' }) async check(): Promise { - const [dbStatus, redisStatus] = await Promise.allSettled([ + const [dbStatus, redisStatus, lexwareStatus] = await Promise.allSettled([ this.checkDatabase(), this.checkRedis(), + this.checkLexware(), ]); - const allUp = - dbStatus.status === 'fulfilled' && - dbStatus.value && - redisStatus.status === 'fulfilled' && - redisStatus.value; + const dbOk = dbStatus.status === 'fulfilled' && dbStatus.value; + const redisOk = redisStatus.status === 'fulfilled' && redisStatus.value; + const lexwareResult: 'up' | 'down' | 'unconfigured' = + lexwareStatus.status === 'fulfilled' + ? lexwareStatus.value + : 'down'; + + // Lexware "unconfigured" ist kein Fehler + const allUp = dbOk && redisOk && lexwareResult !== 'down'; return { status: allUp ? 'ok' : 'error', service: 'crm-service', timestamp: new Date().toISOString(), - version: '0.1.0', + version: '0.2.0', services: { - database: - dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down', - redis: - redisStatus.status === 'fulfilled' && redisStatus.value - ? 'up' - : 'down', + database: dbOk ? 'up' : 'down', + redis: redisOk ? 'up' : 'down', + lexware: lexwareResult, }, }; } @@ -71,4 +77,9 @@ export class HealthController { return false; } } + + private async checkLexware(): Promise<'up' | 'down' | 'unconfigured'> { + if (!this.lexwareClient) return 'unconfigured'; + return this.lexwareClient.isHealthy(); + } } diff --git a/packages/crm-service/src/health/health.module.ts b/packages/crm-service/src/health/health.module.ts index 7476abe..893a0ef 100644 --- a/packages/crm-service/src/health/health.module.ts +++ b/packages/crm-service/src/health/health.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; +import { LexwareModule } from '../lexware/lexware.module'; @Module({ + imports: [LexwareModule], controllers: [HealthController], }) export class HealthModule {} diff --git a/packages/crm-service/src/lexware/dto/link-deal-voucher.dto.ts b/packages/crm-service/src/lexware/dto/link-deal-voucher.dto.ts new file mode 100644 index 0000000..3cd6412 --- /dev/null +++ b/packages/crm-service/src/lexware/dto/link-deal-voucher.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/lexware/dto/link-lexware-contact.dto.ts b/packages/crm-service/src/lexware/dto/link-lexware-contact.dto.ts new file mode 100644 index 0000000..0971e85 --- /dev/null +++ b/packages/crm-service/src/lexware/dto/link-lexware-contact.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/lexware/dto/query-lexware-vouchers.dto.ts b/packages/crm-service/src/lexware/dto/query-lexware-vouchers.dto.ts new file mode 100644 index 0000000..33d3038 --- /dev/null +++ b/packages/crm-service/src/lexware/dto/query-lexware-vouchers.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/lexware/dto/search-lexware-contacts.dto.ts b/packages/crm-service/src/lexware/dto/search-lexware-contacts.dto.ts new file mode 100644 index 0000000..104fda8 --- /dev/null +++ b/packages/crm-service/src/lexware/dto/search-lexware-contacts.dto.ts @@ -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; +} diff --git a/packages/crm-service/src/lexware/interfaces/lexware-api.interfaces.ts b/packages/crm-service/src/lexware/interfaces/lexware-api.interfaces.ts new file mode 100644 index 0000000..715d516 --- /dev/null +++ b/packages/crm-service/src/lexware/interfaces/lexware-api.interfaces.ts @@ -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; +} diff --git a/packages/crm-service/src/lexware/lexware-client.service.ts b/packages/crm-service/src/lexware/lexware-client.service.ts new file mode 100644 index 0000000..fb0828e --- /dev/null +++ b/packages/crm-service/src/lexware/lexware-client.service.ts @@ -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('LEXWARE_RATE_LIMIT', 2); + this.rateLimiter = new RateLimiter(rateLimit, rateLimit); + } + + async onModuleInit(): Promise { + const apiKey = this.config.get('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('/v1/profile'); + return 'up'; + } catch { + return 'down'; + } + } + + /** + * GET Request an Lexware API (rate-limited). + */ + async get( + path: string, + params?: Record, + ): Promise { + this.ensureEnabled(); + return this.executeWithRetry('GET', path, undefined, params); + } + + /** + * POST Request an Lexware API (rate-limited). + */ + async post(path: string, body: unknown): Promise { + this.ensureEnabled(); + return this.executeWithRetry('POST', path, body); + } + + /** + * PUT Request an Lexware API (rate-limited). + */ + async put(path: string, body: unknown): Promise { + this.ensureEnabled(); + return this.executeWithRetry('PUT', path, body); + } + + /** + * DELETE Request an Lexware API (rate-limited). + */ + async delete(path: string): Promise { + this.ensureEnabled(); + await this.executeWithRetry('DELETE', path); + } + + // -------------------------------------------------------- + // Internes: Retry-Logik mit Rate Limiting + // -------------------------------------------------------- + + private async executeWithRetry( + method: string, + path: string, + body?: unknown, + params?: Record, + retries = 3, + ): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await this.rateLimiter.acquire(); + + const response = await firstValueFrom( + method === 'GET' + ? this.httpService.get(path, { params }) + : method === 'POST' + ? this.httpService.post(path, body) + : method === 'PUT' + ? this.httpService.put(path, body) + : this.httpService.delete(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.', + ); + } + } +} diff --git a/packages/crm-service/src/lexware/lexware-contacts.controller.ts b/packages/crm-service/src/lexware/lexware-contacts.controller.ts new file mode 100644 index 0000000..25ffefa --- /dev/null +++ b/packages/crm-service/src/lexware/lexware-contacts.controller.ts @@ -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); + } + } +} diff --git a/packages/crm-service/src/lexware/lexware-contacts.service.ts b/packages/crm-service/src/lexware/lexware-contacts.service.ts new file mode 100644 index 0000000..1be6e5f --- /dev/null +++ b/packages/crm-service/src/lexware/lexware-contacts.service.ts @@ -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 { + const params: Record = {}; + + 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( + '/v1/contacts', + params, + ); + } + + async getContact(lexwareContactId: string): Promise { + return this.lexwareClient.get( + `/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( + '/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( + '/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, + }, + }); + } +} diff --git a/packages/crm-service/src/lexware/lexware-sync.service.ts b/packages/crm-service/src/lexware/lexware-sync.service.ts new file mode 100644 index 0000000..bb4cf6d --- /dev/null +++ b/packages/crm-service/src/lexware/lexware-sync.service.ts @@ -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 { + 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 { + 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 { + try { + const result = await this.redis.setNx(key, 'locked', ttlSeconds); + return result; + } catch { + return false; + } + } + + private async releaseLock(key: string): Promise { + try { + await this.redis.del(key); + } catch { + // Lock wird durch TTL automatisch freigegeben + } + } +} diff --git a/packages/crm-service/src/lexware/lexware-vouchers.controller.ts b/packages/crm-service/src/lexware/lexware-vouchers.controller.ts new file mode 100644 index 0000000..06561e7 --- /dev/null +++ b/packages/crm-service/src/lexware/lexware-vouchers.controller.ts @@ -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() }, + }; + } +} diff --git a/packages/crm-service/src/lexware/lexware-vouchers.service.ts b/packages/crm-service/src/lexware/lexware-vouchers.service.ts new file mode 100644 index 0000000..4554900 --- /dev/null +++ b/packages/crm-service/src/lexware/lexware-vouchers.service.ts @@ -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 { + 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( + '/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( + `/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 }; + } +} diff --git a/packages/crm-service/src/lexware/lexware.module.ts b/packages/crm-service/src/lexware/lexware.module.ts new file mode 100644 index 0000000..7eda161 --- /dev/null +++ b/packages/crm-service/src/lexware/lexware.module.ts @@ -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( + 'LEXWARE_API_URL', + 'https://api.lexware.io', + ), + timeout: 15000, + headers: { + Authorization: `Bearer ${config.get('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 {} diff --git a/packages/crm-service/src/lexware/utils/lexware-mapper.ts b/packages/crm-service/src/lexware/utils/lexware-mapper.ts new file mode 100644 index 0000000..dc625d4 --- /dev/null +++ b/packages/crm-service/src/lexware/utils/lexware-mapper.ts @@ -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 { + const body: Record = { + 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 { + const body: Record = { + 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 = {}; + 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 = { + invoice: 'INVOICE', + quotation: 'QUOTATION', + orderconfirmation: 'ORDER_CONFIRMATION', + creditnote: 'CREDIT_NOTE', + }; + return map[lexwareType.toLowerCase()] || 'INVOICE'; +} + +export function voucherTypeToLexwareEndpoint(type: VoucherType): string { + const map: Record = { + 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 = { + 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); +} diff --git a/packages/crm-service/src/lexware/utils/rate-limiter.ts b/packages/crm-service/src/lexware/utils/rate-limiter.ts new file mode 100644 index 0000000..21a8fae --- /dev/null +++ b/packages/crm-service/src/lexware/utils/rate-limiter.ts @@ -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 { + 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; + } +} diff --git a/packages/crm-service/src/redis/redis.service.ts b/packages/crm-service/src/redis/redis.service.ts index fb0f97f..0a6120b 100644 --- a/packages/crm-service/src/redis/redis.service.ts +++ b/packages/crm-service/src/redis/redis.service.ts @@ -76,4 +76,13 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { async del(key: string): Promise { 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 { + const result = await this.client.set(key, value, 'EX', ttlSeconds, 'NX'); + return result === 'OK'; + } }