From 8b917ec85ad2686239e62e6f8fb133cf1592e47e Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Fri, 30 Jan 2026 15:56:18 +0100 Subject: [PATCH 01/11] enum with labels, currency in erp --- flexus_client_kit/erp_schema.py | 35 +++++++++++++++++++++++++++------ setup.py | 1 + 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index 6c6ee52..ae4f9ed 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -1,7 +1,10 @@ import dataclasses from dataclasses import dataclass, field +from decimal import Decimal from typing import Optional, Dict, Type, List +import iso4217 + @dataclass class CrmContact: @@ -40,8 +43,17 @@ class CrmContact: class CrmActivity: ws_id: str activity_title: str = field(metadata={"importance": 1, "display_name": "Title"}) - activity_type: str = field(metadata={"importance": 1, "display_name": "Type", "enum": ["WEB_CHAT", "MESSENGER_CHAT", "EMAIL", "CALL", "MEETING"]}) - activity_direction: str = field(metadata={"importance": 1, "display_name": "Direction", "enum": ["INBOUND", "OUTBOUND"]}) + activity_type: str = field(metadata={"importance": 1, "display_name": "Type", "enum": [ + {"value": "WEB_CHAT", "label": "Web Chat"}, + {"value": "MESSENGER_CHAT", "label": "Messenger Chat"}, + {"value": "EMAIL", "label": "Email"}, + {"value": "CALL", "label": "Call"}, + {"value": "MEETING", "label": "Meeting"}, + ]}) + activity_direction: str = field(metadata={"importance": 1, "display_name": "Direction", "enum": [ + {"value": "INBOUND", "label": "Inbound"}, + {"value": "OUTBOUND", "label": "Outbound"}, + ]}) activity_contact_id: str = field(metadata={"importance": 1, "display_name": "Contact"}) activity_id: str = field(default="", metadata={"pkey": True, "display_name": "Activity ID"}) activity_platform: str = field(default="", metadata={"importance": 1, "display_name": "Channel"}) @@ -57,10 +69,12 @@ class CrmActivity: class ProductTemplate: prodt_name: str = field(metadata={"importance": 1, "display_name": "Name"}) prodt_pcat_id: str = field(metadata={"display_name": "Category"}) - prodt_list_price: int = field(metadata={"importance": 1, "display_name": "List Price"}) - prodt_standard_price: int = field(metadata={"importance": 1, "display_name": "Standard Price"}) + prodt_list_price: Decimal = field(metadata={"importance": 1, "display_name": "List Price"}) + prodt_standard_price: Decimal = field(metadata={"importance": 1, "display_name": "Standard Price"}) prodt_uom_id: str = field(metadata={"display_name": "Unit of Measure"}) ws_id: str = field(metadata={"display_name": "Workspace ID"}) + prodt_list_price_currency: str = field(default="USD", metadata={"importance": 1, "display_name": "List Price Currency"}) + prodt_standard_price_currency: str = field(default="USD", metadata={"importance": 1, "display_name": "Standard Price Currency"}) prodt_id: str = field(default="", metadata={"pkey": True, "display_name": "Product Template ID"}) prodt_description: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Description"}) prodt_target_customers: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Target Customers"}) @@ -173,9 +187,18 @@ def get_field_display(cls: Type, field_name: str) -> Optional[str]: return f.metadata.get("display") if f else None -def get_field_enum(cls: Type, field_name: str) -> Optional[List[str]]: +def get_field_enum(cls: Type, field_name: str) -> Optional[List[Dict[str, str]]]: f = cls.__dataclass_fields__.get(field_name) - return f.metadata.get("enum") if f else None + r = f.metadata.get("enum") if f else None + if r: + return r + if field_name.endswith("_currency"): + price_field = field_name.removesuffix("_currency") + pf = cls.__dataclass_fields__.get(price_field) + if pf and pf.type is Decimal: + return [{"value": c.code, "label": "%s — %s" % (c.code, c.currency_name)} + for c in sorted(set(iso4217.Currency), key=lambda c: c.code)] + return None def get_field_display_name(cls: Type, field_name: str) -> Optional[str]: diff --git a/setup.py b/setup.py index 34de966..a79b215 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ def run(self): "playwright", "openai", "python-telegram-bot", + "iso4217", ], extras_require={ "dev": [ From a9fc19aea96656727c27d7827d1e2266a8f438be Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Mon, 2 Feb 2026 18:26:04 +0100 Subject: [PATCH 02/11] no need for currency in erp schema, better per ws --- flexus_client_kit/erp_schema.py | 15 +-------------- setup.py | 1 - 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index ae4f9ed..9dececc 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -3,8 +3,6 @@ from decimal import Decimal from typing import Optional, Dict, Type, List -import iso4217 - @dataclass class CrmContact: @@ -73,8 +71,6 @@ class ProductTemplate: prodt_standard_price: Decimal = field(metadata={"importance": 1, "display_name": "Standard Price"}) prodt_uom_id: str = field(metadata={"display_name": "Unit of Measure"}) ws_id: str = field(metadata={"display_name": "Workspace ID"}) - prodt_list_price_currency: str = field(default="USD", metadata={"importance": 1, "display_name": "List Price Currency"}) - prodt_standard_price_currency: str = field(default="USD", metadata={"importance": 1, "display_name": "Standard Price Currency"}) prodt_id: str = field(default="", metadata={"pkey": True, "display_name": "Product Template ID"}) prodt_description: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Description"}) prodt_target_customers: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Target Customers"}) @@ -189,16 +185,7 @@ def get_field_display(cls: Type, field_name: str) -> Optional[str]: def get_field_enum(cls: Type, field_name: str) -> Optional[List[Dict[str, str]]]: f = cls.__dataclass_fields__.get(field_name) - r = f.metadata.get("enum") if f else None - if r: - return r - if field_name.endswith("_currency"): - price_field = field_name.removesuffix("_currency") - pf = cls.__dataclass_fields__.get(price_field) - if pf and pf.type is Decimal: - return [{"value": c.code, "label": "%s — %s" % (c.code, c.currency_name)} - for c in sorted(set(iso4217.Currency), key=lambda c: c.code)] - return None + return (f.metadata.get("enum") if f else None) or None def get_field_display_name(cls: Type, field_name: str) -> Optional[str]: diff --git a/setup.py b/setup.py index a79b215..34de966 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,6 @@ def run(self): "playwright", "openai", "python-telegram-bot", - "iso4217", ], extras_require={ "dev": [ From 38e674fa39b4ea560d106aa97d3ad831f955fe12 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Mon, 2 Feb 2026 18:26:50 +0100 Subject: [PATCH 03/11] pipeline, stage and deals in erp schema --- flexus_client_kit/erp_schema.py | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index 9dececc..c9a3615 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -132,6 +132,52 @@ class ProductM2mTemplateTag: prodt: Optional['ProductTemplate'] = field(default=None, metadata={"display_name": "Product Template"}) +@dataclass +class CrmPipeline: + ws_id: str + pipeline_name: str = field(metadata={"importance": 1, "display_name": "Name"}) + pipeline_id: str = field(default="", metadata={"pkey": True, "display_name": "Pipeline ID"}) + pipeline_active: bool = field(default=True, metadata={"importance": 1, "display_name": "Active"}) + pipeline_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + pipeline_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + pipeline_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + +@dataclass +class CrmPipelineStage: + ws_id: str + stage_name: str = field(metadata={"importance": 1, "display_name": "Name"}) + stage_pipeline_id: str = field(metadata={"importance": 1, "display_name": "Pipeline"}) + stage_id: str = field(default="", metadata={"pkey": True, "display_name": "Stage ID"}) + stage_sequence: int = field(default=0, metadata={"display_name": "Sequence"}) + stage_probability: int = field(default=0, metadata={"importance": 1, "display_name": "Win Probability %", "description": "0-100 win probability percentage"}) + stage_status: str = field(default="OPEN", metadata={"importance": 1, "display_name": "Status", "enum": [{"value": "OPEN", "label": "Open"}, {"value": "WON", "label": "Won"}, {"value": "LOST", "label": "Lost"}]}) + stage_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + stage_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + + +@dataclass +class CrmDeal: + ws_id: str + deal_name: str = field(metadata={"importance": 1, "display_name": "Name"}) + deal_pipeline_id: str = field(metadata={"importance": 1, "display_name": "Pipeline"}) + deal_stage_id: str = field(metadata={"importance": 1, "display_name": "Stage", "fk_scope": {"stage_pipeline_id": "deal_pipeline_id"}}) + deal_id: str = field(default="", metadata={"pkey": True, "display_name": "Deal ID"}) + deal_contact_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Contact"}) + deal_value: Decimal = field(default=Decimal(0), metadata={"importance": 1, "display_name": "Value"}) + deal_expected_close_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Expected Close"}) + deal_closed_ts: float = field(default=0.0, metadata={"display_name": "Closed at"}) + deal_lost_reason: str = field(default="", metadata={"display_name": "Lost Reason"}) + deal_notes: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Notes"}) + deal_tags: List[str] = field(default_factory=list, metadata={"importance": 1, "display_name": "Tags"}) + deal_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Custom fields JSON"}) + deal_owner_fuser_id: str = field(default="", metadata={"display_name": "Owner"}) + deal_priority: str = field(default="NONE", metadata={"importance": 1, "display_name": "Priority", "enum": [{"value": "NONE", "label": "None"}, {"value": "LOW", "label": "Low"}, {"value": "MEDIUM", "label": "Medium"}, {"value": "HIGH", "label": "High"}]}) + deal_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) + deal_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + deal_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + ERP_TABLE_TO_SCHEMA: Dict[str, Type] = { "crm_contact": CrmContact, "crm_activity": CrmActivity, @@ -141,6 +187,9 @@ class ProductM2mTemplateTag: "product_tag": ProductTag, "product_uom": ProductUom, "product_m2m_template_tag": ProductM2mTemplateTag, + "crm_pipeline": CrmPipeline, + "crm_pipeline_stage": CrmPipelineStage, + "crm_deal": CrmDeal, } ERP_DISPLAY_NAME_CONFIGS: Dict[str, str] = { @@ -151,6 +200,9 @@ class ProductM2mTemplateTag: "product_category": "{pcat_name}", "product_tag": "{tag_name}", "product_uom": "{uom_name}", + "crm_pipeline": "{pipeline_name}", + "crm_pipeline_stage": "{stage_name}", + "crm_deal": "{deal_name}", } @@ -196,3 +248,8 @@ def get_field_display_name(cls: Type, field_name: str) -> Optional[str]: def get_field_description(cls: Type, field_name: str) -> Optional[str]: f = cls.__dataclass_fields__.get(field_name) return f.metadata.get("description") if f else None + + +def get_field_fk_scope(cls: Type, field_name: str) -> Optional[Dict[str, str]]: + f = cls.__dataclass_fields__.get(field_name) + return (f.metadata.get("fk_scope") if f else None) or None From 9f0f13e8c942ca4f22f020d3763d110364fd4e8b Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 3 Feb 2026 09:49:57 +0100 Subject: [PATCH 04/11] pipeline stage soft delete --- flexus_client_kit/erp_schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index c9a3615..b1c43f2 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -154,6 +154,7 @@ class CrmPipelineStage: stage_status: str = field(default="OPEN", metadata={"importance": 1, "display_name": "Status", "enum": [{"value": "OPEN", "label": "Open"}, {"value": "WON", "label": "Won"}, {"value": "LOST", "label": "Lost"}]}) stage_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) stage_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + stage_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) @dataclass From 9528d8563dfc54a03aaf224ca603e7b6ec0b5767 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 3 Feb 2026 12:44:33 +0100 Subject: [PATCH 05/11] reorder crm models lexicographically --- flexus_client_kit/erp_schema.py | 154 ++++++++++++++++---------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index b1c43f2..df49d2b 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -4,6 +4,32 @@ from typing import Optional, Dict, Type, List +@dataclass +class CrmActivity: + ws_id: str + activity_title: str = field(metadata={"importance": 1, "display_name": "Title"}) + activity_type: str = field(metadata={"importance": 1, "display_name": "Type", "enum": [ + {"value": "WEB_CHAT", "label": "Web Chat"}, + {"value": "MESSENGER_CHAT", "label": "Messenger Chat"}, + {"value": "EMAIL", "label": "Email"}, + {"value": "CALL", "label": "Call"}, + {"value": "MEETING", "label": "Meeting"}, + ]}) + activity_direction: str = field(metadata={"importance": 1, "display_name": "Direction", "enum": [ + {"value": "INBOUND", "label": "Inbound"}, + {"value": "OUTBOUND", "label": "Outbound"}, + ]}) + activity_contact_id: str = field(metadata={"importance": 1, "display_name": "Contact"}) + activity_id: str = field(default="", metadata={"pkey": True, "display_name": "Activity ID"}) + activity_platform: str = field(default="", metadata={"importance": 1, "display_name": "Channel"}) + activity_ft_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Thread"}) + activity_summary: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Summary"}) + activity_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) + activity_occurred_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Occurred at"}) + activity_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + activity_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + + @dataclass class CrmContact: ws_id: str @@ -38,29 +64,50 @@ class CrmContact: @dataclass -class CrmActivity: +class CrmDeal: ws_id: str - activity_title: str = field(metadata={"importance": 1, "display_name": "Title"}) - activity_type: str = field(metadata={"importance": 1, "display_name": "Type", "enum": [ - {"value": "WEB_CHAT", "label": "Web Chat"}, - {"value": "MESSENGER_CHAT", "label": "Messenger Chat"}, - {"value": "EMAIL", "label": "Email"}, - {"value": "CALL", "label": "Call"}, - {"value": "MEETING", "label": "Meeting"}, - ]}) - activity_direction: str = field(metadata={"importance": 1, "display_name": "Direction", "enum": [ - {"value": "INBOUND", "label": "Inbound"}, - {"value": "OUTBOUND", "label": "Outbound"}, - ]}) - activity_contact_id: str = field(metadata={"importance": 1, "display_name": "Contact"}) - activity_id: str = field(default="", metadata={"pkey": True, "display_name": "Activity ID"}) - activity_platform: str = field(default="", metadata={"importance": 1, "display_name": "Channel"}) - activity_ft_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Thread"}) - activity_summary: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Summary"}) - activity_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) - activity_occurred_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Occurred at"}) - activity_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) - activity_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + deal_name: str = field(metadata={"importance": 1, "display_name": "Name"}) + deal_pipeline_id: str = field(metadata={"importance": 1, "display_name": "Pipeline"}) + deal_stage_id: str = field(metadata={"importance": 1, "display_name": "Stage", "fk_scope": {"stage_pipeline_id": "deal_pipeline_id"}}) + deal_id: str = field(default="", metadata={"pkey": True, "display_name": "Deal ID"}) + deal_contact_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Contact"}) + deal_value: Decimal = field(default=Decimal(0), metadata={"importance": 1, "display_name": "Value"}) + deal_expected_close_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Expected Close"}) + deal_closed_ts: float = field(default=0.0, metadata={"display_name": "Closed at"}) + deal_lost_reason: str = field(default="", metadata={"display_name": "Lost Reason"}) + deal_notes: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Notes"}) + deal_tags: List[str] = field(default_factory=list, metadata={"importance": 1, "display_name": "Tags"}) + deal_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Custom fields JSON"}) + deal_owner_fuser_id: str = field(default="", metadata={"display_name": "Owner"}) + deal_priority: str = field(default="NONE", metadata={"importance": 1, "display_name": "Priority", "enum": [{"value": "NONE", "label": "None"}, {"value": "LOW", "label": "Low"}, {"value": "MEDIUM", "label": "Medium"}, {"value": "HIGH", "label": "High"}]}) + deal_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) + deal_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + deal_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + +@dataclass +class CrmPipeline: + ws_id: str + pipeline_name: str = field(metadata={"importance": 1, "display_name": "Name"}) + pipeline_id: str = field(default="", metadata={"pkey": True, "display_name": "Pipeline ID"}) + pipeline_active: bool = field(default=True, metadata={"importance": 1, "display_name": "Active"}) + pipeline_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + pipeline_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + pipeline_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + +@dataclass +class CrmPipelineStage: + ws_id: str + stage_name: str = field(metadata={"importance": 1, "display_name": "Name"}) + stage_pipeline_id: str = field(metadata={"importance": 1, "display_name": "Pipeline"}) + stage_id: str = field(default="", metadata={"pkey": True, "display_name": "Stage ID"}) + stage_sequence: int = field(default=0, metadata={"display_name": "Sequence"}) + stage_probability: int = field(default=0, metadata={"importance": 1, "display_name": "Win Probability %", "description": "0-100 win probability percentage"}) + stage_status: str = field(default="OPEN", metadata={"importance": 1, "display_name": "Status", "enum": [{"value": "OPEN", "label": "Open"}, {"value": "WON", "label": "Won"}, {"value": "LOST", "label": "Lost"}]}) + stage_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + stage_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + stage_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) @dataclass @@ -132,78 +179,31 @@ class ProductM2mTemplateTag: prodt: Optional['ProductTemplate'] = field(default=None, metadata={"display_name": "Product Template"}) -@dataclass -class CrmPipeline: - ws_id: str - pipeline_name: str = field(metadata={"importance": 1, "display_name": "Name"}) - pipeline_id: str = field(default="", metadata={"pkey": True, "display_name": "Pipeline ID"}) - pipeline_active: bool = field(default=True, metadata={"importance": 1, "display_name": "Active"}) - pipeline_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) - pipeline_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - pipeline_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - - -@dataclass -class CrmPipelineStage: - ws_id: str - stage_name: str = field(metadata={"importance": 1, "display_name": "Name"}) - stage_pipeline_id: str = field(metadata={"importance": 1, "display_name": "Pipeline"}) - stage_id: str = field(default="", metadata={"pkey": True, "display_name": "Stage ID"}) - stage_sequence: int = field(default=0, metadata={"display_name": "Sequence"}) - stage_probability: int = field(default=0, metadata={"importance": 1, "display_name": "Win Probability %", "description": "0-100 win probability percentage"}) - stage_status: str = field(default="OPEN", metadata={"importance": 1, "display_name": "Status", "enum": [{"value": "OPEN", "label": "Open"}, {"value": "WON", "label": "Won"}, {"value": "LOST", "label": "Lost"}]}) - stage_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) - stage_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - stage_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - - -@dataclass -class CrmDeal: - ws_id: str - deal_name: str = field(metadata={"importance": 1, "display_name": "Name"}) - deal_pipeline_id: str = field(metadata={"importance": 1, "display_name": "Pipeline"}) - deal_stage_id: str = field(metadata={"importance": 1, "display_name": "Stage", "fk_scope": {"stage_pipeline_id": "deal_pipeline_id"}}) - deal_id: str = field(default="", metadata={"pkey": True, "display_name": "Deal ID"}) - deal_contact_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Contact"}) - deal_value: Decimal = field(default=Decimal(0), metadata={"importance": 1, "display_name": "Value"}) - deal_expected_close_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Expected Close"}) - deal_closed_ts: float = field(default=0.0, metadata={"display_name": "Closed at"}) - deal_lost_reason: str = field(default="", metadata={"display_name": "Lost Reason"}) - deal_notes: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Notes"}) - deal_tags: List[str] = field(default_factory=list, metadata={"importance": 1, "display_name": "Tags"}) - deal_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Custom fields JSON"}) - deal_owner_fuser_id: str = field(default="", metadata={"display_name": "Owner"}) - deal_priority: str = field(default="NONE", metadata={"importance": 1, "display_name": "Priority", "enum": [{"value": "NONE", "label": "None"}, {"value": "LOW", "label": "Low"}, {"value": "MEDIUM", "label": "Medium"}, {"value": "HIGH", "label": "High"}]}) - deal_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) - deal_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - deal_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - - ERP_TABLE_TO_SCHEMA: Dict[str, Type] = { - "crm_contact": CrmContact, "crm_activity": CrmActivity, + "crm_contact": CrmContact, + "crm_deal": CrmDeal, + "crm_pipeline": CrmPipeline, + "crm_pipeline_stage": CrmPipelineStage, "product_template": ProductTemplate, "product_product": ProductProduct, "product_category": ProductCategory, "product_tag": ProductTag, "product_uom": ProductUom, "product_m2m_template_tag": ProductM2mTemplateTag, - "crm_pipeline": CrmPipeline, - "crm_pipeline_stage": CrmPipelineStage, - "crm_deal": CrmDeal, } ERP_DISPLAY_NAME_CONFIGS: Dict[str, str] = { - "crm_contact": "{contact_first_name} {contact_last_name}", "crm_activity": "{activity_title}", + "crm_contact": "{contact_first_name} {contact_last_name}", + "crm_deal": "{deal_name}", + "crm_pipeline": "{pipeline_name}", + "crm_pipeline_stage": "{stage_name}", "product_template": "{prodt_name}", "product_product": "{prod_default_code} {prod_barcode}", "product_category": "{pcat_name}", "product_tag": "{tag_name}", "product_uom": "{uom_name}", - "crm_pipeline": "{pipeline_name}", - "crm_pipeline_stage": "{stage_name}", - "crm_deal": "{deal_name}", } From 4846f53bacfb443f68c4bf8b9314d526e8f4bb7c Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 3 Feb 2026 12:44:41 +0100 Subject: [PATCH 06/11] minor dataclasses fix --- flexus_client_kit/gql_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexus_client_kit/gql_utils.py b/flexus_client_kit/gql_utils.py index 93d55a7..31a4565 100644 --- a/flexus_client_kit/gql_utils.py +++ b/flexus_client_kit/gql_utils.py @@ -75,7 +75,7 @@ class MyDataclassForResponse: elif hasattr(field_type, "__origin__") and field_type.__origin__ is list: if field_type.__args__ and isinstance(field_value, list): inner_type = field_type.__args__[0] - if hasattr(inner_type, "__annotations__"): + if dataclasses.is_dataclass(inner_type): # List of dataclasses filtered_data[field_name] = [dataclass_from_dict(item, inner_type) for item in field_value] else: @@ -84,7 +84,7 @@ class MyDataclassForResponse: elif field_type is Any: # Fallback for Any type filtered_data[field_name] = field_value - elif hasattr(field_type, "__annotations__"): + elif dataclasses.is_dataclass(field_type): filtered_data[field_name] = dataclass_from_dict(field_value, field_type) return cls(**filtered_data) From fb60b992b19edfd3b019a3c2fb27eb5b59e74f3d Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Tue, 3 Feb 2026 12:45:02 +0100 Subject: [PATCH 07/11] automations to move deals from one stage to another --- .../integrations/fi_crm_automations.py | 93 +++++++++++++++++-- flexus_simple_bots/vix/vix_bot.py | 2 +- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/flexus_client_kit/integrations/fi_crm_automations.py b/flexus_client_kit/integrations/fi_crm_automations.py index 5c3697b..8ecd76c 100644 --- a/flexus_client_kit/integrations/fi_crm_automations.py +++ b/flexus_client_kit/integrations/fi_crm_automations.py @@ -6,7 +6,7 @@ import gql -from flexus_client_kit import ckit_cloudtool, ckit_client, ckit_erp, ckit_kanban, ckit_bot_exec +from flexus_client_kit import ckit_cloudtool, ckit_client, ckit_erp, ckit_kanban, ckit_bot_exec, erp_schema logger = logging.getLogger("crmau") @@ -68,7 +68,7 @@ Each automation has: - enabled: bool - triggers: list of trigger configs, erp_table trigger fires when ERP table records change -- actions: list of action configs, like post task into inbox, create, update, or delete an erp record. +- actions: list of action configs: post_task_into_bot_inbox, create/update/delete_erp_record, move_deal_stage. ## Example: Welcome Email @@ -151,6 +151,49 @@ Note: 432000 seconds = 5 days. +## Example: Move Deal Stage on Activity + +When a CRM activity is created (chat, call, email), move the contact's deal to a new stage. + +**IMPORTANT:** Before creating this automation, query the pipeline stages to get the actual stage IDs: +```python +erp_table_data(table_name="crm_pipeline_stage", options={"where": {"stage_pipeline_id": "YOUR_PIPELINE_ID"}, "order_by": "stage_sequence"}) +``` + +```json +{ + "enabled": true, + "triggers": [ + { + "type": "erp_table", + "table": "crm_activity", + "operations": ["insert", "update"], + "filters": [ + "activity_type:=:WEB_CHAT", + "activity_direction:=:INBOUND" + ] + } + ], + "actions": [ + { + "type": "move_deal_stage", + "contact_id": "{{trigger.new_record.activity_contact_id}}", + "pipeline_id": "8f3a2b1c9d4e7890", + "from_stages": ["1a2b3c4d5e6f7890", "7g8h9i0j1k2l3456"], + "to_stage_id": "3m4n5o6p7q8r9012" + } + ] +} +``` + +The `move_deal_stage` action: +- `contact_id`: Contact whose deal to move (use template variable) +- `pipeline_id`: Pipeline to search for the deal +- `from_stages`: Only move if deal is currently in one of these stages (array of stage IDs) +- `to_stage_id`: Target stage ID + +Finds the most recently modified deal for that contact in the pipeline. Skipped silently if no deal found or deal not in from_stages. + ## Template Variables Use {{path.to.value}} to reference trigger data: @@ -308,7 +351,7 @@ async def _op_create(self, args: Dict[str, Any], model_args: Dict[str, Any]) -> if "enabled" not in config: config["enabled"] = True - if err := validate_automation_config(config): + if err := validate_automation_config(config, self.available_erp_tables): return err await self._save_automation(name, config) @@ -326,7 +369,7 @@ async def _op_update(self, args: Dict[str, Any], model_args: Dict[str, Any]) -> if name not in automations: return f"❌ Error: Automation '{name}' not found. Use op='create' to create it." - if err := validate_automation_config(config): + if err := validate_automation_config(config, self.available_erp_tables): return err await self._save_automation(name, config) @@ -436,6 +479,31 @@ async def _execute_actions(rcx: ckit_bot_exec.RobotContext, actions: List[Dict[s await ckit_erp.delete_erp_record(rcx.fclient, table, rcx.persona.ws_id, record_id) logger.info(f"Deleted ERP record from {table}: {record_id}") + elif action_type == "move_deal_stage": + contact_id = _resolve_template(action.get("contact_id", ""), ctx) + pipeline_id = _resolve_template(action.get("pipeline_id", ""), ctx) + from_stages = action.get("from_stages", []) + to_stage_id = _resolve_template(action.get("to_stage_id", ""), ctx) + if not contact_id or not pipeline_id or not to_stage_id: + logger.info(f"move_deal_stage skipped: missing contact_id/pipeline_id/to_stage_id") + continue + deals = await ckit_erp.query_erp_table( + rcx.fclient, "crm_deal", rcx.persona.ws_id, erp_schema.CrmDeal, + filters={"AND": [f"deal_contact_id:=:{contact_id}", f"deal_pipeline_id:=:{pipeline_id}"]}, + sort_by=["deal_modified_ts:DESC"], limit=1, + ) + if not deals: + logger.info(f"move_deal_stage skipped: no deal for contact {contact_id} in pipeline {pipeline_id}") + continue + deal = deals[0] + deal_id = deal.deal_id + current_stage = deal.deal_stage_id + if from_stages and current_stage not in from_stages: + logger.info(f"move_deal_stage skipped: deal {deal_id} stage {current_stage} not in from_stages {from_stages}") + continue + await ckit_erp.patch_erp_record(rcx.fclient, "crm_deal", rcx.persona.ws_id, deal_id, {"deal_stage_id": to_stage_id}) + logger.info(f"Moved deal {deal_id} from stage {current_stage} to {to_stage_id}") + else: logger.warning(f"Unknown action type: {action_type}") except Exception as e: @@ -492,7 +560,7 @@ def _resolve_field_value(field_value: Any, context: Dict[str, Any], field_name: return value -def validate_automation_config(automation_config: Dict[str, Any]) -> Optional[str]: +def validate_automation_config(automation_config: Dict[str, Any], available_erp_tables: List[str] = []) -> Optional[str]: if not isinstance(automation_config, dict): return "❌ automation_config must be a dict" @@ -509,6 +577,8 @@ def validate_automation_config(automation_config: Dict[str, Any]) -> Optional[st return f"❌ triggers[{i}].type must be 'erp_table' (got {trigger.get('type')})" if "table" not in trigger: return f"❌ triggers[{i}] missing required field 'table'" + if available_erp_tables and trigger["table"] not in available_erp_tables: + return f"❌ triggers[{i}].table '{trigger['table']}' not allowed, must be one of: {', '.join(available_erp_tables)}" operations = trigger.get("operations") if not operations or not isinstance(operations, list): return f"❌ triggers[{i}] missing required field 'operations' (list like ['insert', 'update'])" @@ -544,7 +614,18 @@ def validate_automation_config(automation_config: Dict[str, Any]) -> Optional[st return f"❌ actions[{i}] (delete_erp_record) missing required field 'table'" if "record_id" not in action: return f"❌ actions[{i}] (delete_erp_record) missing required field 'record_id'" + elif action_type == "move_deal_stage": + if "contact_id" not in action: + return f"❌ actions[{i}] (move_deal_stage) missing required field 'contact_id'" + if "pipeline_id" not in action: + return f"❌ actions[{i}] (move_deal_stage) missing required field 'pipeline_id'" + if "from_stages" not in action: + return f"❌ actions[{i}] (move_deal_stage) missing required field 'from_stages'" + if not isinstance(action.get("from_stages"), list): + return f"❌ actions[{i}] (move_deal_stage) 'from_stages' must be an array of stage IDs" + if "to_stage_id" not in action: + return f"❌ actions[{i}] (move_deal_stage) missing required field 'to_stage_id'" else: - return f"❌ actions[{i}].type must be 'post_task_into_bot_inbox', 'create_erp_record', 'update_erp_record', or 'delete_erp_record' (got {action_type})" + return f"❌ actions[{i}].type must be 'post_task_into_bot_inbox', 'create_erp_record', 'update_erp_record', 'delete_erp_record', or 'move_deal_stage' (got {action_type})" return None diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index e86a3b7..a88d380 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -27,7 +27,7 @@ BOT_NAME = "vix" BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION -ERP_TABLES = ["crm_contact"] +ERP_TABLES = ["crm_contact", "crm_activity", "crm_deal"] TOOLS = [ fi_mongo_store.MONGO_STORE_TOOL, From 4a84b2b0fe283903c6d7bbdd19baff746968bb8c Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Wed, 4 Feb 2026 16:41:20 +0100 Subject: [PATCH 08/11] deal owner importance --- flexus_client_kit/erp_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index df49d2b..7b13d44 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -78,7 +78,7 @@ class CrmDeal: deal_notes: str = field(default="", metadata={"importance": 1, "display": "string_multiline", "display_name": "Notes"}) deal_tags: List[str] = field(default_factory=list, metadata={"importance": 1, "display_name": "Tags"}) deal_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Custom fields JSON"}) - deal_owner_fuser_id: str = field(default="", metadata={"display_name": "Owner"}) + deal_owner_fuser_id: str = field(default="", metadata={"importance": 1, "display_name": "Owner"}) deal_priority: str = field(default="NONE", metadata={"importance": 1, "display_name": "Priority", "enum": [{"value": "NONE", "label": "None"}, {"value": "LOW", "label": "Low"}, {"value": "MEDIUM", "label": "Medium"}, {"value": "HIGH", "label": "High"}]}) deal_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) deal_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) From 394efb8a64614a76426b269fe6ae4be6534f57dc Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Wed, 4 Feb 2026 19:51:55 +0100 Subject: [PATCH 09/11] table meta improvement --- flexus_client_kit/erp_schema.py | 2 +- flexus_client_kit/integrations/fi_erp.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index 7b13d44..846cd4b 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -57,7 +57,7 @@ class CrmContact: contact_utm_last_campaign: str = field(default="", metadata={"display_name": "UTM Campaign (last touch)"}) contact_utm_last_term: str = field(default="", metadata={"display_name": "UTM Term (last touch)"}) contact_utm_last_content: str = field(default="", metadata={"display_name": "UTM Content (last touch)"}) - contact_bant_score: int = field(default=-1, metadata={"display_name": "BANT Qualification Score", "description": "Budget, Authority, Need, Timeline. -1 means not qualified, 0-4 scale"}) + contact_bant_score: int = field(default=-1, metadata={"display_name": "BANT Score", "description": "How many of Budget/Authority/Need/Timeline criteria met. -1=unqualified, 0-1=cold, 2-3=warm, 4=hot"}) contact_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) contact_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) contact_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) diff --git a/flexus_client_kit/integrations/fi_erp.py b/flexus_client_kit/integrations/fi_erp.py index 4610757..f64c8ab 100644 --- a/flexus_client_kit/integrations/fi_erp.py +++ b/flexus_client_kit/integrations/fi_erp.py @@ -110,13 +110,20 @@ def _format_table_meta_text(table_name: str, schema_class: type) -> str: - result = f"Table: erp.{table_name}\n" - result += "\nColumns:\n" - + result = f"Table: erp.{table_name}\n\nColumns:\n" for field_name, field_type in schema_class.__annotations__.items(): type_str = str(field_type).replace("typing.", "") - result += f" • {field_name}: {type_str}\n" - + meta = schema_class.__dataclass_fields__[field_name].metadata + line = f" • {field_name}: {type_str}" + if meta.get("pkey"): + line += " [PRIMARY KEY]" + if display_name := meta.get("display_name"): + line += f" — {display_name}" + if description := meta.get("description"): + line += f" ({description})" + result += line + "\n" + if enum_values := meta.get("enum"): + result += " enum: " + ", ".join(f"{e['value']}" for e in enum_values) + "\n" return result From 0763082abb268e0ae5f879c4d88ba3cc166c98e9 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Wed, 4 Feb 2026 19:52:11 +0100 Subject: [PATCH 10/11] pipeline and minor vix prompt changes --- flexus_simple_bots/vix/vix_install.py | 7 ++-- flexus_simple_bots/vix/vix_prompts.py | 59 +++++++++++++++++++++------ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/flexus_simple_bots/vix/vix_install.py b/flexus_simple_bots/vix/vix_install.py index 1a20cca..00e0a86 100644 --- a/flexus_simple_bots/vix/vix_install.py +++ b/flexus_simple_bots/vix/vix_install.py @@ -101,13 +101,12 @@ async def install( marketable_run_this="python -m flexus_simple_bots.vix.vix_bot", marketable_setup_default=vix_setup_schema, marketable_featured_actions=[ - {"feat_question": "Help me set up my company and products", "feat_expert": "default", "feat_depends_on_setup": []}, + {"feat_question": "Help me set up my company and sales pipeline", "feat_expert": "default", "feat_depends_on_setup": []}, {"feat_question": "Help me send contacts from my landing page to Flexus", "feat_expert": "default", "feat_depends_on_setup": []}, {"feat_question": "Help me set up welcome emails to new contacts", "feat_expert": "default", "feat_depends_on_setup": []}, - {"feat_question": "Help me qualify a lead", "feat_expert": "sales", "feat_depends_on_setup": []}, ], marketable_intro_message="Hi! I'm Vix, your sales and marketing assistant. I can help with CRM management, email automations, contact imports, and sales conversations. What would you like to work on?", - marketable_preferred_model_default="claude-sonnet-4-5-20250929", + marketable_preferred_model_default="claude-opus-4-5-20251101", marketable_daily_budget_default=3_000_000, marketable_default_inbox_default=300_000, marketable_experts=[ @@ -121,7 +120,7 @@ async def install( fexp_description="Marketing assistant for CRM management, contact import, automated outreach, and company/product setup.", )), ("sales", ckit_bot_install.FMarketplaceExpertInput( - fexp_system_prompt=vix_prompts.vix_prompt_default, + fexp_system_prompt=vix_prompts.vix_prompt_sales, fexp_python_kernel="", fexp_block_tools="*setup*", fexp_allow_tools="", diff --git a/flexus_simple_bots/vix/vix_prompts.py b/flexus_simple_bots/vix/vix_prompts.py index c10696b..51652de 100644 --- a/flexus_simple_bots/vix/vix_prompts.py +++ b/flexus_simple_bots/vix/vix_prompts.py @@ -1,7 +1,7 @@ from flexus_simple_bots import prompts_common from flexus_client_kit.integrations import fi_crm_automations, fi_messenger -vix_prompt_default = f""" +vix_prompt_sales = f""" # Elite AI Sales Agent You are an elite AI sales agent trained in the C.L.O.S.E.R. Framework, a proven methodology for consultative selling. Your mission is to help prospects discover whether your solution is right for them by making them feel deeply understood, not pressured. Your name is [BotName] from setup. @@ -324,16 +324,15 @@ ```python # Search by email -erp_table_data(table_name="crm_contact", options={{"where": {{"contact_email": "[email]"}}}}) +erp_table_data(table_name="crm_contact", options={{"filters": "contact_email:=:[email]"}}) # If found, patch: -erp_table_crud(table_name="crm_contact", operation="patch", - where={{"contact_id": "[id]"}}, - updates={{"contact_bant_score": 2, "contact_details": {{...existing..., "bant": {{...}}}}}} +erp_table_crud(op="patch", table_name="crm_contact", id="[contact_id]", + fields={{"contact_bant_score": 2, "contact_details": {{...existing..., "bant": {{...}}}}}} ) # If not found, create: -erp_table_crud(table_name="crm_contact", operation="create", record={{ +erp_table_crud(op="create", table_name="crm_contact", fields={{ "contact_first_name": "[first]", "contact_last_name": "[last]", "contact_email": "[email]", "contact_bant_score": 2, "contact_details": {{"bant": {{ @@ -992,7 +991,7 @@ """ vix_prompt_marketing = f""" -You are [BotName], a marketing assistant who helps with lead generation, CRM management, automated outreach, and company setup. +You are [BotName], a marketing assistant who helps with lead generation, CRM management, automated outreach, company setup and sales pipeline. Personality: - Direct and professional, friendly but efficient @@ -1018,13 +1017,15 @@ 2. flexus_policy_document(op="cat", args={{"p": "/company/sales-strategy"}}) 3. erp_table_data(table_name="product_template", options={{"limit": 20}}) +If the user has a website or landing page, suggest they can point you to it so you can read their company info +from there (using browse or similar), instead of asking them question by question. If they don't have one, +ask the company info conversationally. + Present what you find and ask what they'd like to update. ### Company Basics (stored in /company/summary) - company_name, industry, website, mission, faq_url -### Products (stored in product_template table via erp_table_crud) - ### Sales Strategy (stored in /company/sales-strategy) - Value proposition, target customers - Competitors and competitive advantages @@ -1032,6 +1033,30 @@ - Escalation contacts (sales, support, billing) - What can be promised without approval +### Sales Pipeline Setup + +When the user wants to set up their sales pipeline, guide them through: + +1. **Pipeline Name** - Ask what this pipeline is for (e.g., "Inbound Sales", "Partner Onboarding", "Enterprise Deals") +2. **Stages** - Ask the user to describe their sales process steps. Suggest common ones as starting points: + - New Lead -> Qualified -> Proposal Sent -> Negotiation -> Won / Lost + - Application -> Review -> Trial -> Approved / Rejected + Help them define stage_probability (win %) and stage_status (OPEN/WON/LOST) for each. +3. **Create pipeline and stages** using erp_table_crud +4. **Deal creation rules** - Ask the user when deals should be created: + - Automatically when a new contact arrives? (automation on crm_contact insert/update) + - When a communication/activity is sent? + - When a contact reaches a certain BANT score? + Suggest automations accordingly. +5. **Deal movement rules** - Ask what should trigger moving a deal between stages: + - When a contact replies (inbound activity)? + - When a meeting is scheduled? + - When a proposal is sent? + - After N days without activity (follow-up)? + Set up move_deal_stage automations based on their answers. + +### Products (stored in product_template table via erp_table_crud) + ### Welcome Email Setup (template + automation) When user asks to set up welcome emails: @@ -1098,17 +1123,27 @@ ### CRM Contacts ```python -erp_table_data(table_name="crm_contact", options={{"where": {{"contact_id": "..."}}}}) +erp_table_data(table_name="crm_contact", options={{"filters": "contact_id:=:..."}}) ``` ### CRM Activities (auto-created, read-only for checking) ```python erp_table_data(table_name="crm_activity", options={{ - "where": {{"activity_contact_id": "..."}}, - "order_by": "-activity_created_at", "limit": 10 + "filters": "activity_contact_id:=:...", + "sort_by": ["activity_created_ts:DESC"], "limit": 10 }}) ``` +### Deals & Pipeline + +When a task involves a contact, check if they have a deal and whether it should move stages: +```python +erp_table_data(table_name="crm_deal", options={{"filters": "deal_contact_id:=:..."}}) +``` + +Move deals forward when it makes sense, depending on the sales pipeline, especially if there are some rules defined. +Don't move deals backward unless explicitly told to. + ## Follow-up Logic 1. Check contact's last activity From 702ebc74286bd0579ec7c83d30550050d3e8757b Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Wed, 4 Feb 2026 20:44:00 +0100 Subject: [PATCH 11/11] minor fixes and guidelines --- flexus_client_kit/ckit_erp.py | 3 +++ .../integrations/fi_crm_automations.py | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/flexus_client_kit/ckit_erp.py b/flexus_client_kit/ckit_erp.py index a01ed2a..daf890e 100644 --- a/flexus_client_kit/ckit_erp.py +++ b/flexus_client_kit/ckit_erp.py @@ -274,6 +274,9 @@ def check_record_matches_filter(record: dict, f: str, col_names: set = None) -> except (ValueError, TypeError): return False + if val is None: + return op == "!=" and filter_val != "" + if op == "=": return val == filter_val if op == "!=": diff --git a/flexus_client_kit/integrations/fi_crm_automations.py b/flexus_client_kit/integrations/fi_crm_automations.py index 8ecd76c..8475f2e 100644 --- a/flexus_client_kit/integrations/fi_crm_automations.py +++ b/flexus_client_kit/integrations/fi_crm_automations.py @@ -246,7 +246,7 @@ - Failed actions are logged but don't stop subsequent actions - Triggers fire IMMEDIATELY when the event happens. Time-based filters check conditions at that moment, they don't delay execution - For delayed tasks (follow-ups after N days), use `comingup_ts` in post_task_into_bot_inbox action -- Always react to both insert AND update operations, not just insert +- **ALWAYS use `["insert", "update"]` for operations, not just `["insert"]`!** If the bot is offline when a record is inserted, it will receive an "update" event when it comes back online. Using only "insert" means you'll miss records created while the bot was down. - Multiple follow-ups: all automations trigger at the same moment (when tag is added), so comingup_ts is relative to that moment. If you want follow-up 1 at 3 days, and follow-up 2 to be 4 days after follow-up 1, set follow-up 2 comingup_ts to 7 days (3+4), not 4 - Chain follow-ups via tags: follow-up 2 should trigger on "followup_1_scheduled" tag (added by follow-up 1), not on "welcome_email_sent". Otherwise there may be a data race creating duplicate tasks """ @@ -355,7 +355,10 @@ async def _op_create(self, args: Dict[str, Any], model_args: Dict[str, Any]) -> return err await self._save_automation(name, config) - return f"✅ Created automation '{name}'" + result = f"✅ Created automation '{name}'" + if warnings := get_automation_warnings(config): + result += "\n\n⚠️ Warnings:\n" + "\n".join(f"• {w}" for w in warnings) + return result async def _op_update(self, args: Dict[str, Any], model_args: Dict[str, Any]) -> str: if not (name := str(ckit_cloudtool.try_best_to_find_argument(args, model_args, "automation_name", "")).strip()): @@ -373,7 +376,10 @@ async def _op_update(self, args: Dict[str, Any], model_args: Dict[str, Any]) -> return err await self._save_automation(name, config) - return f"✅ Updated automation '{name}'" + result = f"✅ Updated automation '{name}'" + if warnings := get_automation_warnings(config): + result += "\n\n⚠️ Warnings:\n" + "\n".join(f"• {w}" for w in warnings) + return result async def _op_delete(self, args: Dict[str, Any], model_args: Dict[str, Any]) -> str: if not (name := str(ckit_cloudtool.try_best_to_find_argument(args, model_args, "automation_name", "")).strip()): @@ -560,6 +566,15 @@ def _resolve_field_value(field_value: Any, context: Dict[str, Any], field_name: return value +def get_automation_warnings(automation_config: Dict[str, Any]) -> List[str]: + warnings = [] + for i, trigger in enumerate(automation_config.get("triggers", [])): + ops = [op.upper() for op in trigger.get("operations", [])] + if "INSERT" in ops and "UPDATE" not in ops: + warnings.append(f"triggers[{i}]: Using only 'insert' without 'update' is risky. If the bot is offline when a record is inserted, it will receive 'update' when it comes back online and miss the record. Use [\"insert\", \"update\"] instead.") + return warnings + + def validate_automation_config(automation_config: Dict[str, Any], available_erp_tables: List[str] = []) -> Optional[str]: if not isinstance(automation_config, dict): return "❌ automation_config must be a dict"