feat: add team-aware CRM queries and project tracking

This commit is contained in:
nac
2026-08-14 11:00:13 +00:00
parent 4b098eb4dd
commit 76e4cbf58a
13 changed files with 2761 additions and 263 deletions
+187 -9
View File
@@ -210,7 +210,7 @@ def create_server(
] = None,
owner: Annotated[
str | None,
Field(description="Optional owner name, username, email, or English name to filter customers and follow-ups."),
Field(description="Optional user to filter customer ownership and follow-up participation."),
] = None,
) -> dict[str, Any]:
"""Return CRM totals, common customer distributions, and recent follow-ups."""
@@ -233,6 +233,73 @@ def create_server(
"""Return candidate NocoBase users for an owner filter before querying CRM data."""
return _safe_tool_result(lambda: service.resolve_owner(query))
@server.tool()
def list_teams(
query: Annotated[str | None, Field(description="Optional team name search.")] = None,
include_members: Annotated[bool, Field(description="Include resolved team members.")] = False,
formal_members_only: Annotated[
bool,
Field(description="When including members, keep only formal employees."),
] = True,
limit: Annotated[int, Field(description="Maximum teams to return.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
) -> dict[str, Any]:
"""List configured CRM teams and optionally their formal members."""
return _safe_tool_result(
lambda: service.list_teams(
query,
include_members=include_members,
formal_members_only=formal_members_only,
limit=limit,
offset=offset,
)
)
@server.tool()
def resolve_team(
query: Annotated[str, Field(description="Team name to resolve.")],
) -> dict[str, Any]:
"""Resolve a team name before using it as a CRM filter."""
return _safe_tool_result(lambda: service.resolve_team(query))
@server.tool()
def get_team(
team_id: Annotated[Any, Field(description="NocoBase team ID.")],
include_members: Annotated[bool, Field(description="Include team members.")] = True,
formal_members_only: Annotated[
bool,
Field(description="Return only formal employees when members are included."),
] = True,
) -> dict[str, Any]:
"""Get one CRM team, its managers, and optionally its members."""
return _safe_tool_result(
lambda: service.get_team(
team_id,
include_members=include_members,
formal_members_only=formal_members_only,
)
)
@server.tool()
def team_crm_overview(
team: Annotated[str, Field(description="Exact or uniquely matching CRM team name.")],
date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None,
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
include_member_breakdown: Annotated[
bool,
Field(description="Include per-member customer ownership and follow-up contribution."),
] = True,
) -> dict[str, Any]:
"""Return team totals and member contributions using formal CRM team membership."""
return _safe_tool_result(
lambda: service.team_crm_overview(
team,
date_from=date_from,
date_to=date_to,
include_member_breakdown=include_member_breakdown,
)
)
if os.environ.get(ENABLE_CONFIG_TOOLS_ENV, "").strip() == "1":
@server.tool()
@@ -245,17 +312,24 @@ def create_server(
query: Annotated[str | None, Field(description="Optional customer keyword search.")] = None,
status: Annotated[str | None, Field(description="Optional exact customer status.")] = None,
owner: Annotated[str | None, Field(description="Optional customer owner name, username, email, or English name.")] = None,
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
source: Annotated[str | None, Field(description="Optional exact customer source.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False,
) -> dict[str, Any]:
"""List CRM customers with compact rows and pagination metadata."""
"""List CRM customers with compact rows and pagination metadata.
Rows are {id, name, english_name, territorial_name, status, source,
owners: [{id, name}], managers, createdAt, updatedAt}. Empty values are
omitted. A name shown as "#123" means that customer has no name recorded.
"""
return _safe_tool_result(
lambda: service.list_customers(
query=query,
status=status,
owner=owner,
team=team,
source=source,
limit=limit,
offset=offset,
@@ -269,19 +343,27 @@ def create_server(
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_recent_followups: Annotated[bool, Field(description="Include recent related follow-ups.")] = True,
followup_limit: Annotated[int, Field(description="Maximum related follow-ups to return.")] = 10,
include_raw_followups: Annotated[
include_raw: Annotated[
bool,
Field(description="When true, include original NocoBase follow-up rows under raw. Defaults to false."),
Field(description="When true, include original NocoBase rows under raw. Defaults to false."),
] = False,
) -> dict[str, Any]:
"""Get one customer and optionally include contacts and recent follow-ups."""
"""Get one customer with its contacts and recent follow-ups.
Returns {customer, contacts, recent_followups, timezone}. The customer is
{id, name, english_name, territorial_name, status, source, owners, managers,
address, intro, tags, ...} using readable keys, never NocoBase field codes.
Contacts are {id, name, title} without phone or email -- call list_contacts
for those. Follow-ups use the same row shape as list_followups. Empty values
are omitted, so a missing key means empty rather than unknown.
"""
return _safe_tool_result(
lambda: service.get_customer(
customer_id,
include_contacts=include_contacts,
include_recent_followups=include_recent_followups,
followup_limit=followup_limit,
include_raw_followups=include_raw_followups,
include_raw=include_raw,
)
)
@@ -291,15 +373,25 @@ def create_server(
query: Annotated[str | None, Field(description="Optional contact keyword search.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
include_details: Annotated[
bool,
Field(description="Include contact phone and email. Defaults to true."),
] = True,
include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False,
) -> dict[str, Any]:
"""List CRM contacts as compact rows, optionally filtered by customer."""
"""List CRM contacts as compact rows, optionally filtered by customer.
Rows are {id, name, title, phone, email, customers: [{id, name}],
createdAt, updatedAt}. Set include_details=false to omit phone and email.
Empty values are omitted.
"""
return _safe_tool_result(
lambda: service.list_contacts(
customer_id=customer_id,
query=query,
limit=limit,
offset=offset,
include_details=include_details,
include_raw=include_raw,
)
)
@@ -308,19 +400,35 @@ def create_server(
def list_followups(
customer_id: Annotated[Any | None, Field(description="Optional customer primary key for relation filtering.")] = None,
contact_id: Annotated[Any | None, Field(description="Optional contact primary key for relation filtering.")] = None,
owner: Annotated[str | None, Field(description="Optional follow-up owner name, username, email, or English name.")] = None,
owner: Annotated[str | None, Field(description="Optional follow-up participant name, username, email, or English name.")] = None,
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
project_id: Annotated[Any | None, Field(description="Optional CRM project ID.")] = None,
followup_type: Annotated[str | None, Field(description="Optional exact follow-up type value.")] = None,
record_status: Annotated[str | None, Field(description="Optional exact follow-up record status value.")] = None,
date_from: Annotated[str | None, Field(description="Optional lower follow-up date bound.")] = None,
date_to: Annotated[str | None, Field(description="Optional upper follow-up date bound.")] = None,
limit: Annotated[int, Field(description="Maximum rows to return. Defaults to 20.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset. Defaults to 0.")] = 0,
include_raw: Annotated[bool, Field(description="When true, include the original NocoBase row under raw. Defaults to false.")] = False,
) -> dict[str, Any]:
"""List customer follow-up records as compact rows with pagination metadata."""
"""List customer follow-up records as compact rows with pagination metadata.
Rows are {id, followedAt, customers: [{id, name}], contacts, participants,
projects, type, status, location, content}. Content is the full follow-up
text, never truncated. type and status are the display labels; use
crm_filter_options for the codes the followup_type and record_status
filters expect. creator appears only when the submitter is not also a
participant. Empty values are omitted.
"""
return _safe_tool_result(
lambda: service.list_followups(
customer_id=customer_id,
contact_id=contact_id,
owner=owner,
team=team,
project_id=project_id,
followup_type=followup_type,
record_status=record_status,
date_from=date_from,
date_to=date_to,
limit=limit,
@@ -329,6 +437,76 @@ def create_server(
)
)
@server.tool()
def list_projects(
customer_id: Annotated[Any | None, Field(description="Optional customer ID.")] = None,
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
stage: Annotated[str | None, Field(description="Optional project stage label or configured value.")] = None,
limit: Annotated[int, Field(description="Maximum projects to return.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
include_raw: Annotated[bool, Field(description="Include original NocoBase project rows.")] = False,
) -> dict[str, Any]:
"""List CRM projects linked to customers, teams, and project stages."""
return _safe_tool_result(
lambda: service.list_projects(
customer_id=customer_id,
team=team,
stage=stage,
limit=limit,
offset=offset,
include_raw=include_raw,
)
)
@server.tool()
def get_project(
project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")],
include_contacts: Annotated[bool, Field(description="Include related contacts.")] = True,
include_followups: Annotated[bool, Field(description="Include related follow-ups.")] = True,
include_updates: Annotated[bool, Field(description="Include project update records.")] = True,
) -> dict[str, Any]:
"""Get a CRM project with its existing NocoBase relations."""
return _safe_tool_result(
lambda: service.get_project(
project_id,
include_contacts=include_contacts,
include_followups=include_followups,
include_updates=include_updates,
)
)
@server.tool()
def get_project_timeline(
project_id: Annotated[Any, Field(description="NocoBase CRM project ID.")],
date_from: Annotated[str | None, Field(description="Optional lower event date bound.")] = None,
date_to: Annotated[str | None, Field(description="Optional upper event date bound.")] = None,
) -> dict[str, Any]:
"""Return a chronological CRM project timeline from follow-ups and project updates."""
return _safe_tool_result(
lambda: service.get_project_timeline(project_id, date_from=date_from, date_to=date_to)
)
@server.tool()
def list_stale_customers(
team: Annotated[str | None, Field(description="Optional CRM team name.")] = None,
owner: Annotated[str | None, Field(description="Optional customer owner.")] = None,
inactive_days: Annotated[int, Field(description="Days without a valid follow-up.")] = 30,
as_of: Annotated[str | None, Field(description="Reference date in YYYY-MM-DD format.")] = None,
limit: Annotated[int, Field(description="Maximum customers to return.")] = 20,
offset: Annotated[int, Field(description="Zero-based result offset.")] = 0,
) -> dict[str, Any]:
"""List customers whose last valid follow-up is older than the configured threshold."""
return _safe_tool_result(
lambda: service.list_stale_customers(
team=team,
owner=owner,
inactive_days=inactive_days,
as_of=as_of,
limit=limit,
offset=offset,
)
)
if not readonly:
@server.tool()