"""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 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]