[31588f42] feat: implement SuperOffice Sales API prototype and fix auth docs
This commit is contained in:
23
GEMINI.md
23
GEMINI.md
@@ -249,6 +249,29 @@ The full list of 25 verticals with their internal SuperOffice IDs (List `udlist3
|
||||
### 4. Campaign Trigger Logic
|
||||
The `worker.py` (v1.8) now extracts the `campaign_tag` from `SuperOffice:23:DisplayText`. This tag is passed to the Company Explorer's provisioning API. If a matching entry exists in the `MarketingMatrix` for that tag, specific texts are used; otherwise, it falls back to the "standard" Kaltakquise texts.
|
||||
|
||||
### 5. SuperOffice Authentication (Critical Update Feb 28, 2026)
|
||||
|
||||
**Problem:** Authentication failures ("Invalid refresh token" or "Invalid client_id") occurred because standard `load_dotenv()` did not override stale environment variables present in the shell process.
|
||||
|
||||
**Solution:** Always use `load_dotenv(override=True)` in Python scripts to force loading the actual values from the `.env` file.
|
||||
|
||||
**Correct Authentication Pattern (Python):**
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# CRITICAL: override=True ensures we read from .env even if env vars are already set
|
||||
load_dotenv(override=True)
|
||||
|
||||
client_id = os.getenv("SO_CLIENT_ID")
|
||||
# ...
|
||||
```
|
||||
|
||||
**Known Working Config (Production):**
|
||||
* **Environment:** `online3`
|
||||
* **Tenant:** `Cust26720`
|
||||
* **Token Logic:** The `AuthHandler` implementation in `health_check_so.py` is the reference standard. Avoid using legacy `superoffice_client.py` without verifying it uses `override=True`.
|
||||
|
||||
---
|
||||
Here are the available functions:
|
||||
[
|
||||
|
||||
@@ -273,6 +273,29 @@ python3 connector-superoffice/create_email_test.py 193036
|
||||
* `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:**
|
||||
1. **Authentication:** Must use `load_dotenv(override=True)` to ensure production credentials are used (see GEMINI.md).
|
||||
2. **Required Fields for `POST /Sale`:**
|
||||
* `Heading`: The title of the opportunity.
|
||||
* `Amount` & `Currency`: Use `Currency: { "Id": 33 }` (for EUR) instead of string "EUR".
|
||||
* `SaleType`: `{ "Id": 1 }` (General Sale / Allgemeiner Verkauf).
|
||||
* `Stage`: `{ "Id": 10 }` (5% Prospect / Angebot prospektiv).
|
||||
* `Source`: `{ "Id": 2 }` (Proposal Center/Lead Source).
|
||||
* `Contact`: `{ "ContactId": 123 }`.
|
||||
* `Person`: `{ "PersonId": 456 }` (Optional but recommended).
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
This is the core logic used to generate the company-specific opener.
|
||||
|
||||
|
||||
168
connector-superoffice/create_sale_test.py
Normal file
168
connector-superoffice/create_sale_test.py
Normal file
@@ -0,0 +1,168 @@
|
||||
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("create_sale_test")
|
||||
|
||||
# --- 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):
|
||||
resp = requests.get(f"{self.base_url}/{endpoint}", headers=self.headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def _post(self, endpoint, payload):
|
||||
resp = requests.post(f"{self.base_url}/{endpoint}", headers=self.headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def main():
|
||||
try:
|
||||
# Initialize Auth and Client
|
||||
auth = AuthHandler()
|
||||
client = SuperOfficeClient(auth)
|
||||
|
||||
print("\n--- 0. Pre-Flight: Finding Currency ID for EUR ---")
|
||||
currencies = client._get("List/Currency/Items")
|
||||
eur_id = None
|
||||
for curr in currencies:
|
||||
if curr.get('Name') == 'EUR' or curr.get('value') == 'EUR': # Check keys carefully
|
||||
eur_id = curr.get('Id')
|
||||
print(f"✅ Found EUR Currency ID: {eur_id}")
|
||||
break
|
||||
|
||||
if not eur_id:
|
||||
print("⚠️ EUR Currency not found. Defaulting to ID 33 (from discovery).")
|
||||
eur_id = 33
|
||||
|
||||
print("\n--- 1. Finding a Target Contact (Company) ---")
|
||||
contacts = client._get("Contact?$top=1&$select=ContactId,Name")
|
||||
|
||||
if not contacts or 'value' not in contacts or len(contacts['value']) == 0:
|
||||
logger.error("No contacts found in CRM. Cannot create sale.")
|
||||
return
|
||||
|
||||
target_contact = contacts['value'][0]
|
||||
# API returns lowercase keys in this OData format
|
||||
contact_id = target_contact.get('contactId') or target_contact.get('ContactId')
|
||||
contact_name = target_contact.get('name') or target_contact.get('Name')
|
||||
print(f"✅ Found Target Contact: {contact_name} (ID: {contact_id})")
|
||||
|
||||
|
||||
print("\n--- 2. Finding a Person (Optional but recommended) ---")
|
||||
persons_endpoint = f"Contact/{contact_id}/Persons?$top=1&$select=PersonId,FirstName,LastName"
|
||||
persons = client._get(persons_endpoint)
|
||||
|
||||
person_id = None
|
||||
if persons and 'value' in persons and len(persons['value']) > 0:
|
||||
target_person = persons['value'][0]
|
||||
# Fix keys here too
|
||||
person_id = target_person.get('personId') or target_person.get('PersonId')
|
||||
first_name = target_person.get('firstName') or target_person.get('FirstName')
|
||||
last_name = target_person.get('lastName') or target_person.get('LastName')
|
||||
print(f"✅ Found Target Person: {first_name} {last_name} (ID: {person_id})")
|
||||
else:
|
||||
print("⚠️ No person found for this contact. Creating sale without person link.")
|
||||
|
||||
print("\n--- 3. Creating Sale (Opportunity) ---")
|
||||
|
||||
# Construct the payload for a new Sale
|
||||
sale_payload = {
|
||||
"Heading": "AI Prospect: Automation Potential Detected",
|
||||
"Description": "Automated opportunity created by Gemini AI based on high automation potential score.\n\nKey Insights:\n- High robot density potential\n- Manual processes identified",
|
||||
"Amount": 5000.0,
|
||||
# FIXED: Use Object with ID for Currency
|
||||
"Currency": { "Id": eur_id },
|
||||
"SaleType": { "Id": 1 }, # Allgemeiner Verkauf
|
||||
"Stage": { "Id": 10 }, # 5% Angebot prospektiv
|
||||
"Contact": { "ContactId": contact_id },
|
||||
"Source": { "Id": 2 } # Proposal Center
|
||||
}
|
||||
|
||||
if person_id:
|
||||
sale_payload["Person"] = { "PersonId": person_id }
|
||||
|
||||
print(f"Payload Preview: {json.dumps(sale_payload, indent=2)}")
|
||||
|
||||
# Send POST request to create the Sale
|
||||
new_sale = client._post("Sale", sale_payload)
|
||||
|
||||
print("\n--- ✅ SUCCESS: Sale Created! ---")
|
||||
sale_id = new_sale.get('SaleId')
|
||||
sale_number = new_sale.get('SaleNumber')
|
||||
print(f"Sale ID: {sale_id}")
|
||||
print(f"Sale Number: {sale_number}")
|
||||
|
||||
sale_link = f"https://{auth.env}.superoffice.com/{auth.cust_id}/default.aspx?sale_id={sale_id}"
|
||||
print(f"Direct Link: {sale_link}")
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user