"""入职同期 — the people who joined Helix on the very same day as you. A gentle, non-competitive companion to the footprint's "you were the Nth to join": you didn't arrive alone. Lists the colleagues who share your exact hire date, and lights up on the day your whole cohort hits an anniversary. Read-only, self-scoped (resolved from the caller's own ``hire_date``). Names of same-day colleagues are already visible through the org directory, so this adds no new exposure. Departed cohort-mates are counted but not listed by name — the page stays warm and forward-looking rather than a roll of who has left. """ from __future__ import annotations import logging from datetime import datetime from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.tenure import completed_years, is_anniversary from app.core.working_hours import resolve_timezone from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) class CohortMember(BaseModel): id: str display_name: str employee_no: str | None department: str | None avatar_url: str | None class CohortResponse(BaseModel): hire_date: str | None years_together: int | None is_anniversary_today: bool active_members: list[CohortMember] left_count: int generated_at: str # 司龄 math is NOT re-implemented here. This module used to carry its own copy # of the pair below, and it had the exact bug `app/core/tenure.py` was written # to remove: it observed a Feb-29 anchor on Feb 28 but counted the years with # a naive `(month, day)` comparison, so on their anniversary a Feb-29 hire read # 「0 年」 on the 同期 page while footprint / wrapped / their own celebration # post said 1. Two surfaces one click apart, disagreeing. Import, never copy. # (The shared helpers take ``(anchor, as_of)``; the removed local ones took # ``(today, anchor)``. Call sites below use the shared order directly rather # than aliasing, so the argument flip cannot hide in a rename.) async def build_my_cohort( db: AsyncSession, person: IdentityPerson, ) -> CohortResponse: tz = resolve_timezone(person) now = datetime.now(tz) today = now.date() hire_date = person.hire_date if hire_date is None: return CohortResponse( hire_date=None, years_together=None, is_anniversary_today=False, active_members=[], left_count=0, generated_at=now.isoformat(), ) rows = list((await db.execute( select(IdentityPerson).where( IdentityPerson.hire_date == hire_date, IdentityPerson.id != person.id, IdentityPerson.deleted_at.is_(None), ) )).scalars().all()) active_members: list[CohortMember] = [] left_count = 0 for p in rows: if p.employment_status == "left": left_count += 1 continue active_members.append(CohortMember( # No email fallback for a PEER — display_name is the directory # name; falling back to work_email would leak another person's # email into the caller's cohort list ([[reference_field_masking_primitive]]). display_name=p.display_name or p.employee_no or "—", id=str(p.id), employee_no=p.employee_no, department=p.primary_department, avatar_url=p.avatar_url, )) # Stable, friendly ordering — by name so the list doesn't reshuffle per load. active_members.sort(key=lambda m: m.display_name) return CohortResponse( hire_date=hire_date.isoformat(), years_together=completed_years(hire_date, today), is_anniversary_today=is_anniversary(hire_date, today), active_members=active_members, left_count=left_count, generated_at=now.isoformat(), ) __all__ = ["build_my_cohort", "CohortResponse", "CohortMember"]