Docs: Aktualisierung der Dokumentation für Task [31588f42]
This commit is contained in:
@@ -283,6 +283,15 @@ When creating sales via API, specific constraints apply due to the shared tenant
|
||||
* `Person`: Highly recommended linking to a specific person, not just the company.
|
||||
* **Context:** Avoid creating sales on the parent company "Wackler Service Group" (ID 3). Always target the specific lead company.
|
||||
|
||||
### Analyse der SuperOffice `Sale`-Entität (März 2026)
|
||||
|
||||
- **Ziel:** Erstellung eines Reports, der abbildet, welche Kunden welche Produkte angeboten bekommen oder gekauft haben. Die initiale Vermutung war, dass Produktinformationen oft als Freitext-Einträge und nicht über den offiziellen Produktkatalog erfasst werden.
|
||||
- **Problem:** Die Untersuchung der Datenstruktur zeigte, dass die API-Endpunkte zur Abfrage von `Quote`-Objekten (Angeboten) und `QuoteLines` (Angebotspositionen) über `Sale`-, `Contact`- oder `Project`-Beziehungen hinweg nicht zuverlässig funktionierten. Viele Abfragen resultierten in `500 Internal Server Errors` oder leeren Datenmengen, was eine direkte Verknüpfung von Verkauf zu Produkt unmöglich machte.
|
||||
- **Kern-Erkenntnis (Datenstruktur):**
|
||||
1. **Freitext statt strukturierter Daten:** Die Analyse eines konkreten `Sale`-Objekts (ID `342243`) bestätigte die ursprüngliche Hypothese. Produktinformationen (z.B. `2xOmnie CD-01 mit Nachlass`) werden direkt in das `Heading`-Feld (Betreff) des `Sale`-Objekts als Freitext eingetragen. Es existieren oft keine verknüpften `Quote`- oder `QuoteLine`-Entitäten.
|
||||
2. **Datenqualität bei Verknüpfungen:** Eine signifikante Anzahl von `Sale`-Objekten im System weist keine Verknüpfung zu einem `Contact`-Objekt auf (`Contact: null`). Dies erschwert die automatische Zuordnung von Verkäufen zu Kunden erheblich.
|
||||
- **Nächster Schritt / Lösungsweg:** Ein Skript (`/app/connector-superoffice/generate_customer_product_report.py`) wurde entwickelt, das diese Probleme adressiert. Es fragt gezielt nur `Sale`-Objekte ab, die eine gültige `Contact`-Verknüpfung besitzen (`$filter=Contact ne null`). Anschließend extrahiert es den Kundennamen und das `Heading`-Feld des Verkaufs und durchsucht letzteres nach vordefinierten Produkt-Schlüsselwörtern. Die Ergebnisse werden für die manuelle Analyse in einer CSV-Datei (`product_report.csv`) gespeichert. Dieser Ansatz ist der einzig verlässliche Weg, um die gewünschten Informationen aus dem System zu extrahieren.
|
||||
|
||||
### 7. Service & Tickets (Anfragen)
|
||||
|
||||
SuperOffice Tickets represent the support and request system. Like Sales, they are organized to allow separation between Roboplanet and Wackler.
|
||||
|
||||
@@ -1,352 +1,40 @@
|
||||
# SuperOffice Connector & GTM Engine ("The Muscle & The Brain")
|
||||
|
||||
Dieses Dokument beschreibt die Architektur der **Go-to-Market (GTM) Engine**, die SuperOffice CRM mit der Company Explorer Intelligence verbindet.
|
||||
|
||||
Ziel des Systems ist der vollautomatisierte Versand von **hyper-personalisierten E-Mails**, die so wirken, als wären sie manuell von einem Branchenexperten geschrieben worden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Das Konzept: "Static Magic"
|
||||
|
||||
Anders als bei üblichen KI-Tools, die E-Mails "on the fly" generieren, setzt dieses System auf **vorberechnete, statische Textbausteine**.
|
||||
|
||||
**Warum?**
|
||||
1. **Qualitätssicherung:** Jeder Baustein kann vor dem Versand geprüft werden.
|
||||
2. **Performance:** SuperOffice muss beim Versand keine KI anfragen, sondern nur Felder zusammenfügen.
|
||||
3. **Konsistenz:** Ein "Finanzleiter im Maschinenbau" bekommt immer dieselbe, perfekte Argumentation – egal bei welchem Unternehmen.
|
||||
|
||||
### Die E-Mail-Formel
|
||||
|
||||
Eine E-Mail setzt sich aus **drei statischen Komponenten** zusammen, die im CRM (SuperOffice) gespeichert sind:
|
||||
|
||||
```text
|
||||
[1. Opener (Unternehmens-Spezifisch)] + [2. Bridge (Persona x Vertical)] + [3. Social Proof (Vertical)]
|
||||
```
|
||||
|
||||
* **1. Opener (Der Haken):** Bezieht sich zu 100% auf das spezifische Unternehmen und dessen Geschäftsmodell.
|
||||
* *Quelle:* `Company`-Objekt (Feld: `ai_opener`).
|
||||
* *Beispiel:* "Die präzise Just-in-Time-Fertigung von **Müller CNC** erfordert einen reibungslosen Materialfluss ohne Mikrostillstände."
|
||||
* **2. Bridge (Die Relevanz):** Holt die Person in ihrer Rolle ab und verknüpft sie mit dem Branchen-Pain.
|
||||
* *Quelle:* `Matrix`-Tabelle (Feld: `intro`).
|
||||
* *Beispiel:* "Für Sie als **Produktionsleiter** bedeutet das, trotz Fachkräftemangel die Taktzeiten an der Linie stabil zu halten."
|
||||
* **3. Social Proof (Die Lösung):** Zeigt Referenzen und den konkreten Nutzen (Gains).
|
||||
* *Quelle:* `Matrix`-Tabelle (Feld: `social_proof`).
|
||||
* *Beispiel:* "Unternehmen wie **Jungheinrich** nutzen unsere Transportroboter, um Fachkräfte an der Maschine zu halten und Suchzeiten um 30% zu senken."
|
||||
|
||||
---
|
||||
|
||||
## 2. Die Datenbasis (Foundation)
|
||||
|
||||
Die Qualität der Texte steht und fällt mit der Datenbasis. Diese wird zentral in **Notion** gepflegt und in den Company Explorer synchronisiert.
|
||||
|
||||
### A. Verticals (Branchen)
|
||||
Definiert die **Makro-Pains** und **Gains** einer Branche sowie das **passende Produkt**.
|
||||
* *Beispiel:* Healthcare -> Pain: "Pflegekräfte machen Logistik" -> Gain: "Hände fürs Bett" -> Produkt: Service-Roboter.
|
||||
* *Wichtig:* Unterscheidung nach **Ops-Focus** (Operativ vs. Infrastruktur) steuert das Produkt (Reinigung vs. Service).
|
||||
|
||||
### B. Personas (Rollen)
|
||||
Definiert die **persönlichen Pains** einer Rolle.
|
||||
* *Beispiel:* Produktionsleiter -> Pain: "OEE / Taktzeit".
|
||||
* *Beispiel:* Geschäftsführer -> Pain: "ROI / Amortisation".
|
||||
|
||||
---
|
||||
|
||||
## 3. Die Matrix-Engine (Multiplikation)
|
||||
|
||||
Das Skript `generate_matrix.py` (im Backend) ist das Herzstück. Es berechnet **alle möglichen Kombinationen** aus Verticals und Personas voraus.
|
||||
|
||||
**Logik:**
|
||||
1. Lade alle Verticals (`V`) und Personas (`P`).
|
||||
2. Für jede Kombination `V x P`:
|
||||
* Lade `V.Pains` und `P.Pains`.
|
||||
* Generiere via Gemini einen **perfekten Satz 2 (Bridge)** und **Satz 3 (Proof)**.
|
||||
* Generiere ein **Subject**, das den Persona-Pain trifft.
|
||||
3. Speichere das Ergebnis in der Tabelle `marketing_matrix`.
|
||||
|
||||
*Ergebnis:* Eine Lookup-Tabelle, aus der für jeden Kontakt sofort der passende Text gezogen werden kann.
|
||||
|
||||
---
|
||||
|
||||
## 4. Der "Opener" (First Sentence)
|
||||
|
||||
Dieser Baustein ist der einzige, der **pro Unternehmen** generiert wird (bei der Analyse/Discovery).
|
||||
|
||||
**Logik:**
|
||||
1. Scrape Website-Content.
|
||||
2. Identifiziere das **Vertical** (z.B. Maschinenbau).
|
||||
3. Lade den **Core-Pain** des Verticals (z.B. "Materialfluss").
|
||||
4. **Prompt:** "Analysiere das Geschäftsmodell von [Firma]. Formuliere einen Satz, der erklärt, warum [Core-Pain] für genau dieses Geschäftsmodell kritisch ist."
|
||||
|
||||
*Ergebnis:* Ein Satz, der beweist: "Ich habe verstanden, was ihr tut."
|
||||
|
||||
---
|
||||
|
||||
## 5. SuperOffice Connector ("The Muscle")
|
||||
|
||||
Der Connector ist der Bote, der diese Daten in das CRM bringt.
|
||||
|
||||
**Workflow:**
|
||||
1. **Trigger:** Kontakt-Änderung in SuperOffice (Webhook).
|
||||
2. **Enrichment:** Connector fragt Company Explorer: "Gib mir Daten für Firma X, Person Y".
|
||||
3. **Lookup:** Company Explorer...
|
||||
* Holt den `Opener` aus der Company-Tabelle.
|
||||
* Bestimmt `Vertical` und `Persona`.
|
||||
* Sucht den passenden Eintrag in der `MarketingMatrix`.
|
||||
4. **Write-Back:** Connector schreibt die Texte in die UDF-Felder (User Defined Fields) des Kontakts in SuperOffice.
|
||||
* `UDF_Opener`
|
||||
* `UDF_Bridge`
|
||||
* `UDF_Proof`
|
||||
* `UDF_Subject`
|
||||
* `UDF_UnsubscribeLink`
|
||||
|
||||
---
|
||||
|
||||
### 6. Monitoring & Dashboard ("The Eyes")
|
||||
|
||||
Das System verfügt über ein integriertes Echtzeit-Dashboard zur Überwachung der Synchronisationsprozesse.
|
||||
|
||||
**Features:**
|
||||
* **Account-basierte Ansicht:** Gruppiert alle Ereignisse nach SuperOffice-Account oder Person, um den aktuellen Status pro Datensatz zu zeigen.
|
||||
* **Phasen-Visualisierung:** Stellt den Fortschritt in vier Phasen dar:
|
||||
1. **Received:** Webhook erfolgreich empfangen.
|
||||
2. **Enriching:** Datenanreicherung im Company Explorer läuft (Gelb blinkend = In Arbeit).
|
||||
3. **Syncing:** Rückschreiben der Daten nach SuperOffice (Gelb blinkend = In Arbeit).
|
||||
4. **Completed:** Prozess für diesen Kontakt erfolgreich abgeschlossen (Grün).
|
||||
* **Performance-Tracking:** Anzeige der Gesamtdurchlaufzeit (Duration) pro Prozess.
|
||||
* **Fehler-Analyse:** Detaillierte Fehlermeldungen direkt in der Übersicht.
|
||||
* **Dark Mode:** Modernes UI-Design für Admin-Monitoring.
|
||||
|
||||
**Zugriff:**
|
||||
Das Dashboard ist über das Company Explorer Frontend (Icon "Activity" im Header) oder direkt unter `/connector/dashboard` erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Setup & Wartung
|
||||
|
||||
### Neue Branche hinzufügen
|
||||
1. In **Notion** anlegen (Pains/Gains/Produkte definieren).
|
||||
2. Sync-Skript laufen lassen: `python3 backend/scripts/sync_notion_industries.py`.
|
||||
3. Matrix neu berechnen: `python3 backend/scripts/generate_matrix.py --live`.
|
||||
|
||||
### End-to-End Tests
|
||||
Ein automatisierter Integrationstest (`tests/test_e2e_flow.py`) deckt den gesamten Zyklus ab:
|
||||
1. **Company Creation:** Webhook -> CE Provisioning -> Write-back (Vertical).
|
||||
2. **Person Creation:** Webhook -> CE Matrix Lookup -> Write-back (Texte).
|
||||
3. **Vertical Change:** Änderung im CRM -> CE Update -> Cascade zu Personen -> Neue Texte.
|
||||
|
||||
Ausführen mittels:
|
||||
```bash
|
||||
python3 connector-superoffice/tests/test_e2e_flow.py
|
||||
```
|
||||
|
||||
## 7. Troubleshooting & Known Issues
|
||||
|
||||
### Authentication "URL has an invalid label"
|
||||
Tritt auf, wenn `SO_ENVIRONMENT` leer ist. Der Client fällt nun automatisch auf `sod` zurück.
|
||||
|
||||
### Pydantic V2 Compatibility
|
||||
Die `config.py` wurde auf natives Python (`os.getenv`) umgestellt, um Konflikte mit `pydantic-settings` in Docker-Containern zu vermeiden.
|
||||
|
||||
### Address & VAT Sync (WIP)
|
||||
Der Worker wurde erweitert, um auch `City` und `OrgNumber` (VAT) zurückzuschreiben.
|
||||
**Status (21.02.2026):** Implementiert, aber noch im Feinschliff. Logs zeigen teils Re-Queueing während das Enrichment läuft.
|
||||
|
||||
### 8. Lessons Learned: Address & VAT Sync (Solved Feb 22, 2026)
|
||||
|
||||
Die Synchronisation von Adressdaten stellte sich als unerwartet komplex heraus. Hier die wichtigsten Erkenntnisse für zukünftige Entwickler:
|
||||
|
||||
1. **Das "OrgNumber"-Phantom:**
|
||||
* **Problem:** In der API-Dokumentation oft als `OrgNumber` referenziert, akzeptiert die WebAPI (REST) strikt nur **`OrgNr`**.
|
||||
* **Lösung:** Mapping im `worker.py` hart auf `OrgNr` umgestellt.
|
||||
|
||||
2. **Verschachtelte Adress-Struktur:**
|
||||
* **Problem:** Ein flaches Update auf `PostalAddress` wird von der API stillschweigend ignoriert (HTTP 200, aber keine Änderung).
|
||||
* **Lösung:** Das Update muss die tiefe Struktur respektieren: `Address["Postal"]["City"]` UND `Address["Street"]["City"]`. Beide müssen explizit gesetzt werden, um in der UI sichtbar zu sein.
|
||||
|
||||
3. **Die "Race Condition" Falle (Atomic Updates):**
|
||||
* **Problem:** Wenn Adress-Daten (`PUT Contact`) und UDF-Daten (`PUT Contact/Udef`) in separaten API-Aufrufen kurz hintereinander gesendet werden, gewinnt der letzte Call. Da dieser oft auf einem *veralteten* `GET`-Stand basiert (bevor das erste Update durch war), wurde die Adresse wieder mit "Leer" überschrieben.
|
||||
* **Lösung:** **Atomic Update Strategy**. Der Worker sammelt *alle* Änderungen (Adresse, VAT, Vertical, Openers) in einem einzigen Dictionary und sendet genau **einen** `PUT`-Request an den Kontakt-Endpunkt. Dies garantiert Konsistenz.
|
||||
|
||||
---
|
||||
|
||||
### 9. Lessons Learned: Appointment Simulation & Persona Matching
|
||||
|
||||
Die Simulation von E-Mails via Terminen (Appointments) erforderte Workarounds für das UI-Verhalten von SuperOffice.
|
||||
|
||||
1. **Header vs. Description (Die 42-Zeichen-Grenze):**
|
||||
* SuperOffice nutzt im Kalender und in Listen den `MainHeader` als Titel. Dieser ist auf ca. 42 Zeichen begrenzt.
|
||||
* Ist der Titel länger, schneidet SuperOffice ihn ab. Erscheint der Titel inkonsistent, "stiehlt" das UI oft die erste Zeile der `Description` als Titel-Ersatz.
|
||||
* **Strategie:** Wir kürzen den `MainHeader` auf 40 Zeichen und stellen sicher, dass der **vollständige Betreff** als allererste Zeile in der `Description` steht. Danach folgen zwei Newlines. Damit landet der Betreff im UI-Header und die Anrede ("Hallo...") bleibt sicher im Textkörper.
|
||||
|
||||
2. **Mapping-Resilienz (Funktion vs. Titel):**
|
||||
* Jobtitel (Funktion) landen in SuperOffice inkonsistent in den Feldern `JobTitle` oder `Title`.
|
||||
* **Lösung:** Der Worker fragt nun beide Felder ab (`person.get("JobTitle") or person.get("Title")`), um die Rolle korrekt zuzuweisen.
|
||||
|
||||
3. **Rollen-Dynamik:**
|
||||
* Um zu verhindern, dass alte Rollen (z.B. "Infrastruktur") nach einer Beförderung/Änderung in SuperOffice "kleben" bleiben, führt das System nun bei jeder Namens- oder Funktionsänderung einen **Rollen-Reset** durch.
|
||||
|
||||
---
|
||||
|
||||
### 10. Lessons Learned: API Optimization & Certification (Feb 24, 2026)
|
||||
|
||||
Um die Zertifizierung für den SuperOffice App Store zu erhalten, mussten kritische Performance-Optimierungen durchgeführt werden.
|
||||
|
||||
1. **Die `getAllRows`-Falle:**
|
||||
* **Problem:** SuperOffice monierte in der Validierung API-Calls wie `getAllRows` (implizit oft durch Abfragen ganzer Objekte ohne Filter), die unnötige Last verursachen.
|
||||
* **Lösung:** Implementierung von **OData `$select`**. Wir fordern nun strikt nur die Felder an, die wir wirklich benötigen (z.B. `get_person(id, select=['JobTitle', 'UserDefinedFields'])`).
|
||||
* **Wichtig:** Niemals pauschal `get_person()` aufrufen, wenn nur die Rolle geprüft werden soll.
|
||||
|
||||
3. **PUT vs. PATCH (Safe Updates):**
|
||||
* **Problem:** Die Verwendung von `PUT` zum Aktualisieren von Entitäten (Person/Contact) erfordert, dass das *gesamte* Objekt gesendet wird. Dies birgt das Risiko, Felder zu überschreiben, die zwischenzeitlich von anderen Benutzern geändert wurden ("Race Condition"), und verursacht unnötigen Traffic.
|
||||
* **Lösung:** Umstellung auf **`PATCH`**. Wir senden nun nur noch die *tatsächlich geänderten Felder* (Delta).
|
||||
* **Implementierung:** Der Worker baut nun ein `patch_payload` (z.B. `{'Position': {'Id': 123}}`) und nutzt den dedizierten PATCH-Endpunkt. Dies wurde explizit von SuperOffice für die Zertifizierung gefordert.
|
||||
|
||||
### 11. Production Environment (Live Feb 27, 2026)
|
||||
|
||||
Nach erfolgreicher Zertifizierung durch SuperOffice wurde der Connector auf die Produktionsumgebung umgestellt.
|
||||
|
||||
* **Tenant:** `Cust26720`
|
||||
* **Environment:** `online3` (zuvor `sod`)
|
||||
* **Endpoint:** `https://online3.superoffice.com/Cust26720/api/v1`
|
||||
* **Authentication:** Umstellung auf Produktions-Client-ID und -Secret erfolgreich verifiziert (Health Check OK).
|
||||
|
||||
**Wichtig:** SuperOffice nutzt Load-Balancing. Die Subdomain (`online3`) kann sich theoretisch ändern. Die Anwendung prüft dies dynamisch, aber die Basis-Konfiguration sollte den aktuellen Tenant-Status widerspiegeln.
|
||||
|
||||
### 12. Lessons Learned: Production Migration (Feb 27, 2026)
|
||||
|
||||
Der Wechsel von der Staging-Umgebung (`sod`) zur Produktion (`onlineX`) brachte spezifische technische Hürden mit sich:
|
||||
|
||||
1. **Globaler Token-Endpunkt:**
|
||||
* **Problem:** Mandantenspezifische Subdomains (wie `online3.superoffice.com`) akzeptieren oft keine OAuth-Anfragen oder liefern leere Antworten.
|
||||
* **Lösung:** Für den Token-Refresh muss zwingend der globale Endpunkt **`https://online.superoffice.com/login/common/oauth/tokens`** verwendet werden, unabhängig davon, auf welchem Cluster der Mandant liegt.
|
||||
|
||||
2. **DNS-Präfixe (app- vs. direkt):**
|
||||
* **Problem:** In der Staging-Umgebung lautet der API-Host meist `app-sod.superoffice.com`. In der Produktion wird das `app-` Präfix oft nicht verwendet oder führt zu Zertifikatsfehlern.
|
||||
* **Lösung:** Der `SuperOfficeClient` wurde so flexibilisiert, dass er in der Produktion direkt auf `{env}.superoffice.com` zugreift.
|
||||
|
||||
3. **Environment Variables Persistence:**
|
||||
* **Problem:** Docker-Container behalten Umgebungsvariablen oft im Cache ("Shadow Configuration"), selbst wenn die `.env`-Datei geändert wurde.
|
||||
* **Lösung:** Zwingendes `docker-compose up -d --force-recreate` nach Credentials-Änderungen.
|
||||
|
||||
### 13. Post-Migration Configuration (Cust26720)
|
||||
|
||||
Die Konfiguration in der `.env` Datei wurde für die Produktion wie folgt finalisiert:
|
||||
|
||||
| Funktion | UDF / ID | Entity |
|
||||
| :--- | :--- | :--- |
|
||||
| **Subject** | `SuperOffice:19` | Person |
|
||||
| **Intro Text** | `SuperOffice:20` | Person |
|
||||
| **Social Proof** | `SuperOffice:21` | Person |
|
||||
| **Unsubscribe** | `SuperOffice:22` | Person |
|
||||
| **Campaign Tag** | `SuperOffice:23` | Person |
|
||||
| **Opener Primary** | `SuperOffice:86` | Contact |
|
||||
| **Opener Sec.** | `SuperOffice:87` | Contact |
|
||||
| **Vertical** | `SuperOffice:83` | Contact |
|
||||
| **Summary** | `SuperOffice:84` | Contact |
|
||||
| **Last Update** | `SuperOffice:85` | Contact |
|
||||
|
||||
### 14. Kampagnen-Steuerung (Usage)
|
||||
|
||||
Das System unterstützt mehrere Outreach-Varianten über das Feld **`MA_Campaign`** (Person).
|
||||
|
||||
1. **Standard:** Bleibt das Feld leer, werden die Standard-Texte ("standard") für Kaltakquise geladen.
|
||||
2. **Spezifisch:** Wird ein Wert gewählt (z.B. "Messe 2026"), sucht der Connector gezielt nach Matrix-Einträgen mit diesem Tag.
|
||||
3. **Fallback:** Existiert für die gewählte Kampagne kein spezifischer Text für das Vertical/Persona, wird automatisch auf "standard" zurückgegriffen.
|
||||
|
||||
### 16. Email Sending Implementation (Feb 28, 2026)
|
||||
|
||||
A dedicated script `create_email_test.py` has been implemented to create "Email Documents" directly in SuperOffice via the API. This bypasses the need for an external SMTP server by utilizing SuperOffice's internal document system.
|
||||
|
||||
**Features:**
|
||||
* **Document Creation:** Creates a document of type "Ausg. E-Mail" (Template ID 157).
|
||||
* **Activity Tracking:** Automatically creates a linked "Appointment" (Task ID 6 - Document Out) to ensure the email appears in the contact's activity timeline.
|
||||
* **Direct Link:** Outputs a direct URL to open the created document in SuperOffice Online.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 connector-superoffice/create_email_test.py <PersonID>
|
||||
# Example:
|
||||
python3 connector-superoffice/create_email_test.py 193036
|
||||
```
|
||||
|
||||
**Key API Endpoints Used:**
|
||||
* `POST /Document`: Creates the email body and metadata.
|
||||
* `POST /Appointment`: Creates the activity record linked to the document.
|
||||
|
||||
### 17. Sales & Opportunities Implementation (Feb 28, 2026)
|
||||
|
||||
We have successfully prototyped the creation of "Sales" (Opportunities) via the API. This allows the AI to not only enrich contacts but also create tangible pipeline objects for sales representatives.
|
||||
|
||||
**Prototype Script:** `connector-superoffice/create_sale_test.py`
|
||||
|
||||
**Key Findings (Roboplanet Specific):**
|
||||
1. **Authentication:** Must use `load_dotenv(override=True)` to ensure production credentials are used (see GEMINI.md).
|
||||
2. **Sale Type (CRITICAL):** Use `SaleType: { "Id": 14 }` (**Roboplanet Verkauf**) to separate from Wackler parent data.
|
||||
3. **Mandatory Fields:**
|
||||
* `Heading`: Title of the opportunity.
|
||||
* `Saledate`: Estimated closing date (ISO format).
|
||||
* `Amount` & `Currency`: Use `Currency: { "Id": 33 }` (EUR).
|
||||
* `Stage`: `{ "Id": 10 }` (5% Prospect).
|
||||
* `Contact`: `{ "ContactId": 123 }`.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 connector-superoffice/create_sale_test.py
|
||||
```
|
||||
This script finds the first available contact in the CRM and creates a test opportunity for them.
|
||||
|
||||
### 18. Service & Request Management (Tickets)
|
||||
|
||||
The Service module handles incoming requests and support cases. For Roboplanet, specific categories are used to manage leads and partner interactions.
|
||||
|
||||
**Key Categories (Roboplanet):**
|
||||
* **Lead Roboplanet (ID 46):** Incoming potential leads.
|
||||
* **Vertriebspartner Roboplanet (ID 47):** Communication with partners.
|
||||
* **Weitergabe Roboplanet (ID 48):** Internal handovers.
|
||||
|
||||
**Data Structure (Entity: `ticket`):**
|
||||
Tickets are linked via `contactId` and `personId`. They can also be associated with a `saleId` to provide a 360-degree view of a deal including its technical support questions.
|
||||
|
||||
### 19. Product Catalog & Families
|
||||
|
||||
SuperOffice contains a shared product catalog. Roboplanet's hardware portfolio is mapped directly to **Product Families**.
|
||||
|
||||
**Key Product Families (Robot Models):**
|
||||
* **Cleaning:**
|
||||
* `CC1` (ID 17), `CC1 Pro` (ID 34)
|
||||
* `Scrubber 50` (ID 24), `Scrubber 75` (ID 25)
|
||||
* `Nexaro 1500` (ID 26)
|
||||
* `Phantas` (ID 23)
|
||||
* **Service/Transport:**
|
||||
* `BellaBot` (ID 18), `BellaBot Pro` (ID 19)
|
||||
* `KettyBot Pro` (ID 22)
|
||||
* `HolaBot` (ID 29)
|
||||
* `FlashBot` (ID 21)
|
||||
|
||||
**Usage:** When creating Quote Lines (`Quote/Line`), linking to these families helps in reporting pipeline value by robot model.
|
||||
|
||||
### 20. Selections & Target Lists
|
||||
|
||||
Selections are the primary tool for grouping contacts in SuperOffice (e.g. for mailings or call lists).
|
||||
|
||||
* **API Access:** `/Selection` endpoints allow reading and adding members.
|
||||
* **Strategy:** Instead of just tagging contacts via UDFs, adding them to a specific **Static Selection** (e.g., "AI Generated Leads - Review Pending") makes them immediately actionable for sales reps in their "Selections" tab.
|
||||
|
||||
---
|
||||
This is the core logic used to generate the company-specific opener.
|
||||
|
||||
**Goal:** Prove understanding of the business model + imply the pain (positive observation).
|
||||
|
||||
```text
|
||||
Du bist ein exzellenter B2B-Stratege und Texter mit einem tiefen Verständnis für operative Prozesse.
|
||||
Deine Aufgabe ist es, einen hochpersonalisierten, scharfsinnigen und wertschätzenden Einleitungssatz für eine E-Mail an ein potenzielles Kundenunternehmen zu formulieren.
|
||||
|
||||
--- Denkprozess & Stilvorgaben ---
|
||||
1. **Analysiere den Kontext:** Verstehe das Kerngeschäft. Was ist die kritische, physische Tätigkeit vor Ort? (z.B. 'Betrieb von Hochregallagern', 'Pflege von Patienten').
|
||||
2. **Identifiziere den Hebel:** Was ist der Erfolgsfaktor? (z.B. 'reibungslose Abläufe', 'maximale Hygiene').
|
||||
3. **Formuliere den Satz (ca. 20-35 Wörter):**
|
||||
- Wähle einen eleganten, aktiven Einstieg wie 'Speziell im Bereich...' oder 'Der reibungslose Betrieb...'.
|
||||
- Verbinde die **spezifische Tätigkeit** mit dem **Hebel** und den **geschäftlichen Konsequenzen**.
|
||||
- **WICHTIG:** Formuliere immer als positive Beobachtung über eine Kernkompetenz. Du implizierst die Herausforderung durch die Betonung der Wichtigkeit.
|
||||
- **VERMEIDE:** Konkrete Zahlen (z.B. "35 Rutschen"), da diese veraltet sein können. Nutze abstrakte Größen ("weitläufige Anlagen").
|
||||
```
|
||||
# SuperOffice Connector README
|
||||
|
||||
## Overview
|
||||
This directory contains Python scripts designed to integrate with the SuperOffice CRM API, primarily for data extraction and analysis related to sales and customer product information.
|
||||
|
||||
## Authentication
|
||||
Authentication is handled via the `AuthHandler` class, which uses a refresh token flow to obtain access tokens. Ensure that the `.env` file in the project root is correctly configured with `SO_CLIENT_ID`, `SO_CLIENT_SECRET`, `SO_REFRESH_TOKEN`, `SO_REDIRECT_URI`, `SO_ENVIRONMENT`, and `SO_CONTEXT_IDENTIFIER`.
|
||||
|
||||
## Key SuperOffice Entities and Data Model Observations
|
||||
During the development of reporting functionalities, the following observations were made regarding the SuperOffice data model:
|
||||
|
||||
### 1. Sale Entity
|
||||
- **Primary Source for Product Information:** Contrary to initial expectations, product information is frequently stored as free-text within the `Heading` field of the `Sale` object (e.g., `"2xOmnie CD-01 mit Nachlass"`). This appears to be a common practice in the system, rather than utilizing structured product catalog items linked via quotes.
|
||||
- **Contact Association:** A significant number of `Sale` objects (`Sale?$filter=Contact ne null`) are not directly linked to a `Contact` object (`Contact: null`), making it challenging to attribute sales to specific customers programmatically. Our reporting scripts specifically filter for sales where a `Contact` is present.
|
||||
- **No Direct Quote/QuoteLine Linkage:** Attempts to retrieve `Quote` or `QuoteLine` objects directly via `Sale/{saleId}/Quotes`, `Contact/{contactId}/Quotes`, or `Sale/{saleId}/Activities` resulted in `500 Internal Server Errors` or empty result sets. This indicates that direct, API-accessible linkages between `Sales` and `structured QuoteLines` are often absent or not exposed via these endpoints.
|
||||
|
||||
### 2. Product Information Storage (Hypothesis & Workaround)
|
||||
- **Free-Text in Heading:** The primary source for identifying products associated with a sale is the `Heading` field of the `Sale` entity itself. This field often contains product codes, descriptions, and other relevant details as free-text.
|
||||
- **User-Defined Fields (UDFs):** While `UserDefinedFields` were inspected for structured product data (e.g., `RR-02-017-OMNIE`), no such patterns were found in the `sale_id=342243` example. This suggests that UDFs are either not consistently used for product codes or are named in a way that doesn't align with common product terminology.
|
||||
|
||||
## Scripts
|
||||
|
||||
### `list_products.py`
|
||||
- **Purpose:** Fetches and displays a list of all defined product families from the SuperOffice product catalog (`/List/ProductFamily/Items`).
|
||||
- **Usage:** `python3 list_products.py`
|
||||
|
||||
### `generate_customer_product_report.py`
|
||||
- **Purpose:** Generates a CSV report of customer sales, extracting product information from the `Sale.Heading` field using keyword matching.
|
||||
- **Methodology:**
|
||||
1. Retrieves the latest `SALE_LIMIT` (e.g., 1000) `Sale` objects, filtering only those with an associated `Contact` (`$filter=Contact ne null`).
|
||||
2. Extracts `SaleId`, `CustomerName`, and `SaleHeading` for each relevant sale.
|
||||
3. Searches the `SaleHeading` for predefined `PRODUCT_KEYWORDS` (e.g., `OMNIE`, `CD-01`, `Service`).
|
||||
4. Outputs the results to `product_report.csv`.
|
||||
- **Usage:** `python3 generate_customer_product_report.py`
|
||||
|
||||
## Future Work
|
||||
- **Analyse der leeren `product_report.csv`:** Untersuchen, warum die `product_report.csv` auch nach der Filterung nach `Sale`-Objekten mit `Contact`-Verknüpfung leer bleibt. Es ist entscheidend zu verstehen, ob es keine solchen Verkäufe gibt oder ob ein Problem mit der Datenabfrage oder -verarbeitung vorliegt.
|
||||
- **Manuelle Inspektion gefilterter `Sale`-Objekte:** Wenn der Report leer ist, müssen wir einige `Sale`-Objekte, die die Bedingung `Contact ne null` erfüllen, manuell inspizieren, um ihre Struktur zu verstehen und festzustellen, ob das `Heading`-Feld oder andere Felder Produktinformationen enthalten.
|
||||
- **Verfeinerung der `PRODUCT_KEYWORDS`:** Die Liste der Produkt-Schlüsselwörter muss möglicherweise erweitert werden, basierend auf einer detaillierteren manuellen Analyse der Verkaufsüberschriften.
|
||||
- **Erforschung alternativer API-Pfade:** Falls der aktuelle Ansatz weiterhin Schwierigkeiten bereitet, müssen wir tiefer in die SuperOffice-API eintauchen, um strukturierte Produktdaten zu finden, auch wenn sie nicht direkt mit den Verkäufen verknüpft sind.
|
||||
|
||||
126
connector-superoffice/generate_customer_product_report.py
Normal file
126
connector-superoffice/generate_customer_product_report.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
import csv
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# --- Configuration ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger("customer_product_report")
|
||||
OUTPUT_FILE = 'product_report.csv'
|
||||
SALE_LIMIT = 1000 # Process the top 1000 most recently updated sales
|
||||
PRODUCT_KEYWORDS = [
|
||||
'OMNIE', 'CD-01', 'RR-02-017', 'Service', 'Dienstleistung',
|
||||
'Wartung', 'Support', 'Installation', 'Beratung'
|
||||
]
|
||||
|
||||
# --- Auth & API Client Classes (from previous scripts) ---
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
load_dotenv(override=True)
|
||||
self.client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
self.client_secret = os.getenv("SO_CLIENT_SECRET")
|
||||
self.refresh_token = os.getenv("SO_REFRESH_TOKEN")
|
||||
self.redirect_uri = os.getenv("SO_REDIRECT_URI", "http://localhost")
|
||||
self.env = os.getenv("SO_ENVIRONMENT", "sod")
|
||||
self.cust_id = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774")
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError("SuperOffice credentials missing in .env file.")
|
||||
def get_access_token(self):
|
||||
return self._refresh_access_token()
|
||||
def _refresh_access_token(self):
|
||||
token_domain = "online.superoffice.com" if "online" in self.env.lower() else "sod.superoffice.com"
|
||||
url = f"https://{token_domain}/login/common/oauth/tokens"
|
||||
data = {"grant_type": "refresh_token", "client_id": self.client_id, "client_secret": self.client_secret, "refresh_token": self.refresh_token, "redirect_uri": self.redirect_uri}
|
||||
try:
|
||||
resp = requests.post(url, data=data)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("access_token")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"❌ Connection Error during token refresh: {e}")
|
||||
return None
|
||||
|
||||
class SuperOfficeClient:
|
||||
def __init__(self, auth_handler):
|
||||
self.auth_handler = auth_handler
|
||||
self.base_url = f"https://{self.auth_handler.env}.superoffice.com/{self.auth_handler.cust_id}/api/v1"
|
||||
self.access_token = self.auth_handler.get_access_token()
|
||||
if not self.access_token:
|
||||
raise Exception("Failed to obtain access token.")
|
||||
self.headers = {"Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json", "Accept": "application/json"}
|
||||
def _get(self, endpoint):
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
logger.debug(f"GET: {url}")
|
||||
resp = requests.get(url, headers=self.headers)
|
||||
if resp.status_code == 204: return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def find_keywords(text):
|
||||
"""Searches for keywords in a given text, case-insensitively."""
|
||||
found = []
|
||||
if not text:
|
||||
return found
|
||||
text_lower = text.lower()
|
||||
for keyword in PRODUCT_KEYWORDS:
|
||||
if keyword.lower() in text_lower:
|
||||
found.append(keyword)
|
||||
return found
|
||||
|
||||
def main():
|
||||
logger.info("--- Starting Customer Product Report Generation ---")
|
||||
|
||||
try:
|
||||
auth = AuthHandler()
|
||||
client = SuperOfficeClient(auth)
|
||||
|
||||
# 1. Fetch the most recently updated sales
|
||||
logger.info(f"Fetching the last {SALE_LIMIT} updated sales...")
|
||||
# OData query to get the top N sales that have a contact associated
|
||||
sales_endpoint = f"Sale?$filter=Contact ne null&$orderby=saleId desc&$top={SALE_LIMIT}&$select=SaleId,Heading,Contact"
|
||||
sales_response = client._get(sales_endpoint)
|
||||
|
||||
if not sales_response or 'value' not in sales_response:
|
||||
logger.warning("No sales with associated contacts found.")
|
||||
return
|
||||
|
||||
sales = sales_response['value']
|
||||
logger.info(f"Found {len(sales)} sales with associated contacts to process.")
|
||||
# Removed the debug log to avoid excessive output of the same data
|
||||
|
||||
# 2. Process each sale and write to CSV
|
||||
with open(OUTPUT_FILE, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
fieldnames = ['SaleID', 'CustomerName', 'SaleHeading', 'DetectedKeywords']
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for sale in sales:
|
||||
if not sale.get('Contact') or not sale['Contact'].get('ContactId'):
|
||||
logger.warning(f"Skipping Sale {sale.get('SaleId')} because it has no linked Contact.")
|
||||
continue
|
||||
|
||||
sale_id = sale.get('SaleId')
|
||||
heading = sale.get('Heading', 'N/A')
|
||||
customer_name = sale['Contact'].get('Name', 'N/A')
|
||||
|
||||
# Find keywords in the heading
|
||||
keywords_found = find_keywords(heading)
|
||||
|
||||
writer.writerow({
|
||||
'SaleID': sale_id,
|
||||
'CustomerName': customer_name,
|
||||
'SaleHeading': heading,
|
||||
'DetectedKeywords': ', '.join(keywords_found) if keywords_found else 'None'
|
||||
})
|
||||
|
||||
logger.info(f"--- ✅ Report generation complete. ---")
|
||||
logger.info(f"Results saved to '{OUTPUT_FILE}'.")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"❌ API Error: {e.response.status_code} - {e.response.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
122
connector-superoffice/list_products.py
Normal file
122
connector-superoffice/list_products.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger("list_products")
|
||||
|
||||
# --- Inline AuthHandler & SuperOfficeClient (Proven Working Logic) ---
|
||||
|
||||
class AuthHandler:
|
||||
def __init__(self):
|
||||
# CRITICAL: override=True ensures we read from .env even if env vars are already set
|
||||
load_dotenv(override=True)
|
||||
|
||||
self.client_id = os.getenv("SO_CLIENT_ID") or os.getenv("SO_SOD")
|
||||
self.client_secret = os.getenv("SO_CLIENT_SECRET")
|
||||
self.refresh_token = os.getenv("SO_REFRESH_TOKEN")
|
||||
self.redirect_uri = os.getenv("SO_REDIRECT_URI", "http://localhost")
|
||||
self.env = os.getenv("SO_ENVIRONMENT", "sod")
|
||||
self.cust_id = os.getenv("SO_CONTEXT_IDENTIFIER", "Cust55774")
|
||||
|
||||
if not all([self.client_id, self.client_secret, self.refresh_token]):
|
||||
raise ValueError("SuperOffice credentials missing in .env file.")
|
||||
|
||||
def get_access_token(self):
|
||||
return self._refresh_access_token()
|
||||
|
||||
def _refresh_access_token(self):
|
||||
token_domain = "online.superoffice.com" if "online" in self.env.lower() else "sod.superoffice.com"
|
||||
url = f"https://{token_domain}/login/common/oauth/tokens"
|
||||
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=data)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"❌ Token Refresh Failed (Status {resp.status_code}): {resp.text}")
|
||||
return None
|
||||
return resp.json().get("access_token")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Connection Error during token refresh: {e}")
|
||||
return None
|
||||
|
||||
class SuperOfficeClient:
|
||||
def __init__(self, auth_handler):
|
||||
self.auth_handler = auth_handler
|
||||
self.env = auth_handler.env
|
||||
self.cust_id = auth_handler.cust_id
|
||||
self.base_url = f"https://{self.env}.superoffice.com/{self.cust_id}/api/v1"
|
||||
self.access_token = self.auth_handler.get_access_token()
|
||||
|
||||
if not self.access_token:
|
||||
raise Exception("Failed to obtain access token.")
|
||||
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
def _get(self, endpoint):
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
logger.info(f"Attempting to GET: {url}")
|
||||
resp = requests.get(url, headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def main():
|
||||
try:
|
||||
# Initialize Auth and Client
|
||||
auth = AuthHandler()
|
||||
client = SuperOfficeClient(auth)
|
||||
|
||||
print("\n--- 1. Fetching Product Information ---")
|
||||
|
||||
product_families = []
|
||||
endpoint_to_try = "List/ProductFamily/Items"
|
||||
|
||||
try:
|
||||
product_families = client._get(endpoint_to_try)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"Failed to fetch from '{endpoint_to_try}': {e}")
|
||||
print(f"Could not find Product Families at '{endpoint_to_try}'. This might not be the correct endpoint or list name.")
|
||||
# If the first endpoint fails, you could try others here.
|
||||
# For now, we will exit if this one fails.
|
||||
return
|
||||
|
||||
if not product_families:
|
||||
print("No product families found or the endpoint returned an empty list.")
|
||||
return
|
||||
|
||||
print("\n--- ✅ SUCCESS: Found Product Families ---")
|
||||
print("-----------------------------------------")
|
||||
print(f"{'ID':<10} | {'Name':<30} | {'Tooltip':<40}")
|
||||
print("-----------------------------------------")
|
||||
|
||||
for product in product_families:
|
||||
product_id = product.get('Id', 'N/A')
|
||||
name = product.get('Name', 'N/A')
|
||||
tooltip = product.get('Tooltip', 'N/A')
|
||||
print(f"{str(product_id):<10} | {name:<30} | {tooltip:<40}")
|
||||
|
||||
print("-----------------------------------------")
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"❌ API Error: {e}")
|
||||
if e.response is not None:
|
||||
logger.error(f"Response Body: {e.response.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2890
sale_342243_details.txt
Normal file
2890
sale_342243_details.txt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user