"""Pydantic v2 response models for the yunke data-mirror API.""" from __future__ import annotations import datetime as dt from typing import Any, Generic, TypeVar from pydantic import BaseModel, ConfigDict T = TypeVar("T") class CustomerOut(BaseModel): """Public Customer view — a subset of :class:`app.db.models.Customer`. The structured columns power the list table; the verbatim upstream record is exposed as ``raw_json`` so the frontend can render the full ~60-field 云客 客户详情 (all-fields drawer) without a second endpoint. The model's ``raw_json`` column is picked up automatically via ``from_attributes``. """ model_config = ConfigDict(from_attributes=True) friend_id: str channel: str owner_account_id: str | None = None friend_nickname: str | None = None friend_remark: str | None = None real_name: str | None = None phone: str | None = None region: str | None = None sale_name: str | None = None dept_name: str | None = None head_image_url: str | None = None last_chat_time: int | None = None add_time: int | None = None message_count: int | None = None version: str | None = None # Verbatim upstream friend record (~60 camelCase fields). Surfaced so the # all-fields drawer can render every column the 云客 source page shows # (friendWxAlias/addTime/createTime/fromType/addType/...) without the # backend having to enumerate each one as a structured column. raw_json: dict[str, Any] | None = None class Page(BaseModel, Generic[T]): """Generic pagination envelope returned by list endpoints.""" items: list[T] total: int page: int page_size: int # --------------------------------------------------------------------------- # Organization & people # --------------------------------------------------------------------------- class DepartmentOut(BaseModel): """云客 department node — frontend builds the tree from ``parent_id``/``path``.""" model_config = ConfigDict(from_attributes=True) dept_id: str parent_id: str | None = None name: str | None = None path: str | None = None class StaffOut(BaseModel): """Staff member (employee) row. The structured columns power the list table; the verbatim upstream record is exposed as ``raw_json`` so the frontend can render the all-fields drawer without a second endpoint. Picked up automatically via ``from_attributes``. """ model_config = ConfigDict(from_attributes=True) user_id: str name: str | None = None phone: str | None = None sale_phone: str | None = None dept_id: str | None = None is_on_job: bool | None = None raw_json: dict[str, Any] | None = None class AccountOut(BaseModel): """Unified staff messaging account (WeChat or WhatsApp). The two source tables (:class:`WechatAccount`, :class:`WhatsappAccount`) have different PK / column names; this view projects both onto a single shape keyed by ``channel`` + ``account_id`` so the frontend can render one list. Built explicitly in the route handler (not via ``from_attributes``) because no single ORM row carries this schema. ``raw_json`` carries the verbatim upstream record (wechatId/alias/sendNum/receiveNum/dbSize/...) so the all-fields drawer can render every column the 云客 微信号管理 page shows. """ channel: str account_id: str nickname: str | None = None user_id: str | None = None user_name: str | None = None phone: str | None = None contacts_num: int | None = None group_num: int | None = None is_on_job: bool | None = None raw_json: dict[str, Any] | None = None # --------------------------------------------------------------------------- # Communication entities # --------------------------------------------------------------------------- class MessageOut(BaseModel): """Chat timeline row — a single message (text/media/voice/...). The structured columns power the bubble rendering; the verbatim upstream record is exposed as ``raw_json`` so the frontend can render the all-fields drawer (msgSvrId/keyId, file/fileTh/fileHd, md5/md5Th/md5Hd, paymsgid, referMsg, imgPath, ...) without a second endpoint. Picked up automatically via ``from_attributes``. """ model_config = ConfigDict(from_attributes=True) msg_id: str channel: str owner_account_id: str | None = None talker_id: str | None = None room_id: str | None = None is_send: bool | None = None type: str | None = None type_raw: int | None = None text: str | None = None file_url: str | None = None # Image thumbnails / originals + voice duration / transcription so the # timeline can render media without a second round-trip. All optional — the # underlying columns (see Message model) are nullable. file_th_url: str | None = None file_hd_url: str | None = None duration: int | None = None transcription_text: str | None = None md5: str | None = None sender_name: str | None = None sender_head_url: str | None = None timestamp: int | None = None raw_json: dict[str, Any] | None = None class GroupOut(BaseModel): """Chat group summary row. The structured columns power the list table; the verbatim upstream record is exposed as ``raw_json`` so the frontend can render the all-fields drawer (chatRoomId/chatRoomName, memberIds, headImageUrl, wechatId/wechatNickName, userName/userPhone, messageCount/ownMessageCount, lossMembers/addMembers, lastChatTime/updateTime, ...) without a second endpoint. Picked up automatically via ``from_attributes``. """ model_config = ConfigDict(from_attributes=True) room_id: str channel: str room_name: str | None = None owner_account_id: str | None = None owner_account_nickname: str | None = None member_count: int | None = None message_count: int | None = None last_chat_time: int | None = None raw_json: dict[str, Any] | None = None class GroupMemberOut(BaseModel): """Member of a chat group (composite PK: channel + room_id + member_id).""" model_config = ConfigDict(from_attributes=True) channel: str room_id: str member_id: str nickname: str | None = None display_name: str | None = None avatar: str | None = None class PhoneCallOut(BaseModel): """Phone / voice call record. ``channel`` is the call-type. The structured columns power the list table; the verbatim upstream record is exposed as ``raw_json`` so the frontend can render the all-fields drawer (id/startCallTime, callDuration/callSeconds, ringTime, callStatus, incomingCall, userId/userIdName, departmentId, callNumber, simPhone/simNumber/imei, recordFile, isUpload, downloadCount, aiAsr, qcStatus, planCustomerName, version + voice-call extras ossFileName/ duration/startTime/endTime/remark) without a second endpoint. Picked up automatically via ``from_attributes``. """ model_config = ConfigDict(from_attributes=True) call_id: str channel: str start_call_time: int | None = None call_duration: int | None = None call_status: int | None = None incoming_call: int | None = None user_id: str | None = None user_id_name: str | None = None call_number: str | None = None sim_phone: str | None = None # OSS URL of the call recording — surfaced so the Calls view can offer a # "播放录音" action when present. record_file: str | None = None # sha256 media_hash of the localized recording (NULL until downloaded). record_media_hash: str | None = None raw_json: dict[str, Any] | None = None class SmsOut(BaseModel): """SMS log row. The structured columns power the list table; the verbatim upstream record is exposed as ``raw_json`` so the frontend can render the all-fields drawer (id/customerPhone, sms/smsSub, sender, direction, userId, senderDepartment, receiveName, createTime, simPhone/simNumber, source) without a second endpoint. Picked up automatically via ``from_attributes``. """ model_config = ConfigDict(from_attributes=True) sms_id: str customer_phone: str | None = None content_preview: str | None = None # Full SMS body (the table stores it in ``content``); included so the SMS # view can show the whole message in a detail dialog without a second call. content: str | None = None sender: str | None = None direction: int | None = None user_id: str | None = None receive_name: str | None = None create_time: int | None = None sim_phone: str | None = None raw_json: dict[str, Any] | None = None # --------------------------------------------------------------------------- # Sync monitor # --------------------------------------------------------------------------- class SyncStateOut(BaseModel): """Resumable sync cursor (one per entity/channel/scope).""" model_config = ConfigDict(from_attributes=True) state_id: int entity_type: str channel: str | None = None scope_id: str | None = None last_version: str | None = None last_timestamp: int | None = None last_synced_at: dt.datetime | None = None class SyncRunOut(BaseModel): """Audit row for a single sync execution.""" model_config = ConfigDict(from_attributes=True) run_id: int entity_type: str channel: str | None = None scope_id: str | None = None status: str started_at: dt.datetime finished_at: dt.datetime | None = None records_fetched: int records_upserted: int error: str | None = None class SyncStatusOut(BaseModel): """Aggregate monitor payload — current cursors plus recent runs.""" states: list[SyncStateOut] recent_runs: list[SyncRunOut] # --------------------------------------------------------------------------- # Staff chat stats (WeChat staff 5-number summary + customer detail) # --------------------------------------------------------------------------- class StaffChatCustomerOut(BaseModel): """单个客户在日期窗内的私聊聚合行(本地 message 表派生)。""" talker_id: str friend_name: str # remark > nickname > real_name > talker_id send_count: int # 员工发出(is_send True) receive_count: int # 客户发出(is_send False) interaction_days: int # 窗口内不同日期数 last_is_send: int | None # 1=员工最后发, 0=客户最后发, None=最后一条方向未知(不计入两率) last_time: int | None = None # ms-epoch,最后一条时间 class StaffChatStatsSummary(BaseModel): """5 个汇总数字(从 customers 派生)。""" chat_count: int # 沟通人数:窗口内 ≥1 条消息的去重客户数 effective_count: int # 有效沟通数:send≥1 且 receive≥1 的客户数 follow_count: int # 跟进客户数:interaction_days≥2 的客户数(近似口径) last_reply_count: int # 员工最后回复数:last_is_send=1 的客户数 no_reply_count: int # 未回复客户数:last_is_send=0 的客户数 class StaffChatStatsOut(BaseModel): owner_account_id: str start: int # ms-epoch end: int # ms-epoch summary: StaffChatStatsSummary customers: list[StaffChatCustomerOut]