from __future__ import annotations from .crm_mixin import CrmMixin from .crm_shared import ( FOLLOWUP_DATE_KEYS, FOLLOWUP_TYPE_LABELS, INVALID_FOLLOWUP_STATUS, Any, ZoneInfo, _decorate_dimension_counts, _decorate_id_counts, _first_text, _format_user_candidate, _int_or_default, _normalize_pagination, _paginated_rows, _parse_datetime, _ref_names, _relation_ids, build_and_filter, build_date_range_condition, build_relation_id_condition, build_relation_id_in_condition, date, datetime, ) class CrmReportingMixin(CrmMixin): def team_crm_overview( self, team: str, *, date_from: str | None = None, date_to: str | None = None, include_member_breakdown: bool = True, ) -> dict[str, Any]: matches = self._team_matches((team or "").strip()) member_ids, error = self._resolve_team_member_ids_or_error(team) if error: return {"error": error} assert member_ids is not None team_row = matches[0] members = self._team_users(team_row, self.config.team.member_field, formal_only=True) customer_filter = build_and_filter( [build_relation_id_in_condition(self.config.customer.owner_field, member_ids)] ) followup_filter = build_and_filter( [ build_relation_id_in_condition(self.config.followup.owner_field, member_ids), build_date_range_condition(self.config.followup.date_field, date_from, date_to), {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, ] ) project_filter = build_and_filter( [ build_relation_id_in_condition( [self.config.project.customer_relation_field, self.config.customer.owner_field, "id"], member_ids, ), { self.config.project.progress_field: { "$ne": self.config.project.progress_values["终止"], } }, ] ) result: dict[str, Any] = { "team": self._compact_team(team_row, include_members=True, formal_members_only=True), "period": {"date_from": date_from, "date_to": date_to}, "timezone": self.config.timezone, "totals": { "owned_customers": self._count( self.config.customer.resource, self.config.customer.id_field, customer_filter, ), "followup_records": self._count( self.config.followup.resource, self.config.followup.id_field, followup_filter, ), "active_projects": self._count( self.config.project.resource, self.config.project.id_field, project_filter, ), }, "by_followup_type": self._group_count( self.config.followup.resource, self.config.followup.id_field, self.config.followup.type_field, followup_filter, ), "by_customer": self._group_count( self.config.followup.resource, self.config.followup.id_field, [self.config.followup.customer_field, "id"], followup_filter, ), "by_project_stage": self._group_count( self.config.project.resource, self.config.project.id_field, self.config.project.progress_field, project_filter, ), "contribution_basis": { "participant_field": self.config.followup.owner_field, "team_totals": "unique follow-up record IDs", "member_contribution": "one participation per member per follow-up", }, } if include_member_breakdown: breakdown: list[dict[str, Any]] = [] for member in members: member_id = member.get(self.config.user.id_field) if member_id is None: continue member_customer_filter = build_and_filter( [build_relation_id_condition(self.config.customer.owner_field, member_id)] ) member_followup_filter = build_and_filter( [ build_relation_id_condition(self.config.followup.owner_field, member_id), build_date_range_condition(self.config.followup.date_field, date_from, date_to), {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, ] ) candidate = _format_user_candidate(member, self.config.user.id_field) breakdown.append( { "member": candidate, "owned_customers": self._count( self.config.customer.resource, self.config.customer.id_field, member_customer_filter, ), "followup_participations": self._count( self.config.followup.resource, self.config.followup.id_field, member_followup_filter, ), } ) result["by_member"] = sorted( breakdown, key=lambda item: (-item["followup_participations"], str(item["member"].get("label") or "")), ) result["by_followup_type"] = _decorate_dimension_counts( result["by_followup_type"], preferred_keys=[self.config.followup.type_field], labels=FOLLOWUP_TYPE_LABELS, ) result["by_project_stage"] = _decorate_dimension_counts( result["by_project_stage"], preferred_keys=[self.config.project.progress_field], labels={value: label for label, value in self.config.project.progress_values.items()}, ) result["by_customer"] = self._attach_customer_names( _decorate_id_counts( result["by_customer"], preferred_keys=[ f"{self.config.followup.customer_field}_id", "gjjl_client_id", ], output_key="customer_id", ) ) return result def get_project_timeline( self, project_id: Any, *, date_from: str | None = None, date_to: str | None = None, ) -> dict[str, Any]: project_result = self.get_project( project_id, include_contacts=False, include_followups=False, include_updates=False, ) if "error" in project_result: return project_result project = project_result["project"] followup_rows, followups_truncation = self._scan_records( self.config.followup.resource, filter=build_and_filter( [ build_relation_id_condition(self.config.followup.project_field, project_id), build_date_range_condition(self.config.followup.date_field, date_from, date_to), ] ), fields=self._followup_compact_fields(), sort=self.config.followup.sort, ) update_rows, updates_truncation = self._scan_records( self.config.project.update_resource, filter=build_and_filter( [ build_relation_id_condition(self.config.project.update_project_field, project_id), build_date_range_condition(self.config.project.update_date_field, date_from, date_to), ] ), fields=[ "id", self.config.project.update_date_field, self.config.project.update_content_field, "createdBy", "createdAt", ], sort=[self.config.project.update_date_field, "createdAt"], ) events: list[dict[str, Any]] = [] for row in followup_rows: compact = self._compact_followup(row) events.append( { "event_type": "followup", "occurred_at": compact.get("followedAt"), "actors": _ref_names(compact.get("participants", [])), "summary": compact.get("content"), "source_resource": self.config.followup.resource, "source_id": compact.get("id"), } ) for row in update_rows: compact = self._compact_project_update(row) events.append( { "event_type": "project_update", "occurred_at": compact.get("updated_at"), "actors": _ref_names([compact["creator"]] if compact.get("creator") else []), "summary": compact.get("content"), "source_resource": self.config.project.update_resource, "source_id": compact.get("id"), } ) events.sort(key=lambda event: str(event.get("occurred_at") or "")) result = { "project": project, "events": events, "timezone": self.config.timezone, } warnings: list[str] = [] truncation: dict[str, Any] = {} if followups_truncation: warnings.append("follow-up events were truncated at the scan limit") truncation["followups"] = followups_truncation if updates_truncation: warnings.append("project update events were truncated at the scan limit") truncation["updates"] = updates_truncation if warnings: result["data_quality_warnings"] = warnings result["truncation"] = truncation return result def list_stale_customers( self, *, team: str | None = None, owner: str | None = None, inactive_days: int = 30, as_of: str | None = None, limit: int = 20, offset: int = 0, ) -> dict[str, Any]: limit, offset = _normalize_pagination(limit, offset) inactive_days = max(1, _int_or_default(inactive_days, 30)) owner_id, owner_error = self._resolve_owner_id_or_error(owner) if owner_error: return {"error": owner_error} team_member_ids, team_error = self._resolve_team_member_ids_or_error(team) if team_error: return {"error": team_error} try: as_of_date = date.fromisoformat(as_of) if as_of else datetime.now(ZoneInfo(self.config.timezone)).date() except ValueError: return {"error": {"code": "invalid_date", "message": "as_of must use YYYY-MM-DD"}} customer_filter = build_and_filter( [ build_relation_id_condition(self.config.customer.owner_field, owner_id), build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids), ] ) followup_filter = build_and_filter( [ build_relation_id_condition(self.config.followup.owner_field, owner_id), build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids), {self.config.followup.date_field: {"$dateNotAfter": as_of_date.isoformat()}}, {self.config.followup.status_field: {"$ne": INVALID_FOLLOWUP_STATUS}}, ] ) customer_rows, customers_truncation = self._scan_records( self.config.customer.resource, filter=customer_filter, fields=self._customer_compact_fields(), sort=self.config.customer.sort, ) followup_rows, followups_truncation = self._scan_records( self.config.followup.resource, filter=followup_filter, # This scan can pull thousands of rows and only needs the customer id, # so the customer relation is projected down to exactly that. fields=[ self.config.followup.id_field, f"{self.config.followup.customer_field}.{self.config.customer.id_field}", self.config.followup.date_field, self.config.followup.status_field, ], sort=self.config.followup.sort, ) last_followup: dict[Any, datetime] = {} for followup in followup_rows: followed_at = _parse_datetime( _first_text( followup, [self.config.followup.date_field, *FOLLOWUP_DATE_KEYS], ), self.config.timezone, ) if followed_at is None: continue for customer_id in _relation_ids( followup.get(self.config.followup.customer_field), self.config.customer.id_field, ): previous = last_followup.get(customer_id) if previous is None or followed_at > previous: last_followup[customer_id] = followed_at stale: list[dict[str, Any]] = [] for customer in customer_rows: customer_id = customer.get(self.config.customer.id_field) last = last_followup.get(customer_id) days_since = (as_of_date - last.astimezone(ZoneInfo(self.config.timezone)).date()).days if last else None if days_since is not None and days_since < inactive_days: continue stale.append( { "customer": self._compact_customer(customer), "last_followup_at": (last.astimezone(ZoneInfo(self.config.timezone)).isoformat() if last else None), "days_since_last_followup": days_since, "never_followed_up": last is None, } ) stale.sort( key=lambda item: ( item["days_since_last_followup"] is not None, -(item["days_since_last_followup"] or 0), str(item["customer"].get("name") or ""), ) ) selected = stale[offset : offset + limit] result = _paginated_rows(selected, len(stale), limit, offset) result.update( { "as_of": as_of_date.isoformat(), "inactive_days": inactive_days, "timezone": self.config.timezone, } ) warnings: list[str] = [] truncation: dict[str, Any] = {} if customers_truncation: warnings.append("customer scan was truncated") truncation["customers"] = customers_truncation if followups_truncation: warnings.append("follow-up scan was truncated") truncation["followups"] = followups_truncation if warnings: result["data_quality_warnings"] = warnings result["truncation"] = truncation return result