This commit is contained in:
Your Name
2026-08-12 17:18:56 +08:00
parent 28cd110dae
commit f48a66b611
24 changed files with 3095 additions and 233 deletions
@@ -361,12 +361,48 @@ class DiagnosisController extends BaseAdminController
// 传递当前管理员ID
$params['admin_id'] = $this->adminId;
$result = DiagnosisLogic::startCall($params);
if ($result) {
return $this->success('发起通话成功', [], 1, 1);
}
return $this->fail(DiagnosisLogic::getError());
}
$result = DiagnosisLogic::startCall($params, $this->adminInfo);
if ($result !== false) {
return $this->data($result);
}
return $this->fail(DiagnosisLogic::getError());
}
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
public function startCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::startCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 幂等写入当前通话的已完成转写分段 */
public function upsertCallTranscriptSegments()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/** @notes 完成当前通话转写并固化对话文字 */
public function finishCallTranscription()
{
$params = $this->request->post();
$params['admin_id'] = (int)$this->adminId;
$result = DiagnosisLogic::finishCallTranscription($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->data($result);
}
/**
* @notes 结束通话
@@ -959,11 +959,12 @@ class ConversionLogic
/**
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
*
* 口径(对齐企微客户列表,而非原始回调条数):
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users
@@ -986,7 +987,7 @@ class ConversionLogic
}
}
$baseKey = self::requestRowsCacheKey('fans-effective-v2', [
$baseKey = self::requestRowsCacheKey('fans-effective-v3', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
@@ -1029,6 +1030,8 @@ class ConversionLogic
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->where('e.user_id', '<>', '')
->where('e.external_userid', '<>', '')
->whereRaw(
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
. ' WHERE audit_e.user_id = e.user_id'
@@ -1047,8 +1050,8 @@ class ConversionLogic
. ' AND del_e.event_time <= ?)',
['del_external_contact', $endTimestamp]
)
->fieldRaw('e.user_id, COUNT(DISTINCT e.external_userid) AS add_fans_count')
->group('e.user_id');
->field(['e.user_id', 'e.external_userid'])
->group('e.user_id, e.external_userid');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
}
@@ -1056,11 +1059,114 @@ class ConversionLogic
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
}
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
$pairs = $query->select()->toArray();
$pairs = self::excludeInheritedFanPairs($pairs);
$countsByUser = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
if ($userId === '') {
continue;
}
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
}
$rows = [];
foreach ($countsByUser as $userId => $count) {
$rows[] = [
'user_id' => $userId,
'add_fans_count' => $count,
];
}
self::$requestRowsCache[$cacheKey] = $rows;
return self::$requestRowsCache[$cacheKey];
}
/**
* 剔除企微「继承/分配」客户:跟进人 add_way 为 201(内部成员共享)或 202(管理员/负责人分配,含在职/离职继承)。
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
*
* @param array<int, array<string, mixed>> $pairs
* @return array<int, array<string, mixed>>
*/
private static function excludeInheritedFanPairs(array $pairs): array
{
if ($pairs === []) {
return [];
}
$externalUserIds = [];
foreach ($pairs as $pair) {
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($extId !== '') {
$externalUserIds[$extId] = true;
}
}
$externalIdList = array_keys($externalUserIds);
if ($externalIdList === []) {
return $pairs;
}
/** @var array<string, true> $inheritedKeys user_id\0external_userid */
$inheritedKeys = [];
foreach (array_chunk($externalIdList, 500) as $chunk) {
$contactRows = Db::name('qywx_external_contact')
->whereIn('external_userid', $chunk)
->field(['external_userid', 'follow_users'])
->select()
->toArray();
foreach ($contactRows as $contact) {
$extId = trim((string) ($contact['external_userid'] ?? ''));
if ($extId === '') {
continue;
}
$followUsers = $contact['follow_users'] ?? null;
if (\is_string($followUsers) && $followUsers !== '') {
$decoded = json_decode($followUsers, true);
$followUsers = \is_array($decoded) ? $decoded : [];
}
if (!\is_array($followUsers)) {
continue;
}
foreach ($followUsers as $fu) {
if (!\is_array($fu)) {
continue;
}
$addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0);
if ($addWay !== 201 && $addWay !== 202) {
continue;
}
$followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? ''));
if ($followUserId === '') {
continue;
}
$inheritedKeys[$followUserId . "\0" . $extId] = true;
}
}
}
if ($inheritedKeys === []) {
return $pairs;
}
$kept = [];
foreach ($pairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? ''));
$extId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $extId === '') {
continue;
}
if (isset($inheritedKeys[$userId . "\0" . $extId])) {
continue;
}
$kept[] = $pair;
}
return $kept;
}
/**
* @param string[] $userIds
* @return array<string, array{id: int|string, name: string}>
@@ -1625,10 +1731,13 @@ class ConversionLogic
$entity['account_cost'] = $effectiveAccountCost;
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
// 预约率:面诊 / 预约(看预约后未到面)
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
// 接诊率:接诊诊单 / 总进线(加粉);医生维度仍用面诊作分母
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
// 面诊接诊率:接诊诊单 / 面诊
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
// 面诊接诊率:面诊 / 挂号
// 面诊率:面诊 / 挂号(看挂号后流失)
$entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -2711,6 +2820,10 @@ class ConversionLogic
return $charts;
}
/**
* 接诊率:接诊诊单 ÷ 总进线(加粉)。
* 医生维度无加粉口径时,退化为接诊诊单 ÷ 面诊。
*/
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
{
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
+372 -30
View File
@@ -1572,32 +1572,67 @@ class DiagnosisLogic extends BaseLogic
/**
* @notes 发起通话
* @param array $params
* @return bool
*/
public static function startCall(array $params): bool
{
try {
// 获取当前管理员ID(从参数中获取)
$adminId = $params['admin_id'] ?? 0;
if (!$adminId) {
self::setError('获取管理员信息失败');
return false;
}
// 创建通话记录
\app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $params['diagnosis_id'],
'caller_id' => $adminId,
'caller_type' => 'doctor',
'callee_id' => $params['patient_id'] ?? 0,
'callee_type' => 'patient',
'call_type' => $params['call_type'] ?? 2, // 1-语音 2-视频
* @return array{call_record_id:int}|false
*/
public static function startCall(array $params, array $adminInfo = [])
{
try {
$adminId = (int)($params['admin_id'] ?? 0);
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$patientId = (int)($params['patient_id'] ?? 0);
$callType = (int)($params['call_type'] ?? 2);
if ($adminId <= 0 || $diagnosisId <= 0 || $patientId <= 0) {
self::setError('通话身份参数无效');
return false;
}
if (!in_array($callType, [1, 2], true)) {
self::setError('通话类型无效');
return false;
}
$diagnosisQuery = Diagnosis::where('id', $diagnosisId)
->where('patient_id', $patientId);
$roleIds = array_map(
'intval',
AdminRole::where('admin_id', $adminId)->column('role_id')
);
if (in_array(2, $roleIds, true)) {
$diagnosisQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return false;
}
if (is_array($visibleIds)) {
$diagnosisQuery->whereIn('assistant_id', $visibleIds);
}
}
$diagnosis = $diagnosisQuery->find();
if (!$diagnosis) {
self::setError('诊单不存在、患者不匹配或无权访问');
return false;
}
// 创建通话记录
$record = \app\common\model\tcm\CallRecord::create([
'diagnosis_id' => $diagnosisId,
'caller_id' => $adminId,
'caller_type' => 'doctor',
'callee_id' => $patientId,
'callee_type' => 'patient',
'call_type' => $callType,
'status' => 1, // 1-进行中
'start_time' => time()
]);
return true;
'start_time' => time()
]);
$callRecordId = (int)($record['id'] ?? 0);
if ($callRecordId <= 0) {
self::setError('创建通话记录后未取得记录ID');
return false;
}
return ['call_record_id' => $callRecordId];
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
@@ -1807,7 +1842,7 @@ class DiagnosisLogic extends BaseLogic
* @param array $params
* @return array
*/
public static function getCallRecords(array $params): array
public static function getCallRecords(array $params): array
{
try {
$records = \app\common\model\tcm\CallRecord::where('diagnosis_id', $params['diagnosis_id'])
@@ -1826,16 +1861,323 @@ class DiagnosisLogic extends BaseLogic
$urls = $decoded;
}
}
$record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
}
$record['recording_urls_list'] = $urls;
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
$record['transcription_status_text'] = self::transcriptionStatusText(
(string)($record['transcription_status'] ?? '')
);
}
return $records;
} catch (\Exception $e) {
self::setError($e->getMessage());
return [];
}
}
}
/**
* @notes 为指定通话记录创建幂等的实时转写会话
* @return array|false
*/
public static function startCallTranscription(array $params)
{
try {
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
$language = trim((string)($params['language'] ?? 'zh-CN'));
if ($sessionId === '' || mb_strlen($sessionId) > 128) {
self::setError('转写会话ID无效');
return false;
}
if ($language === '' || mb_strlen($language) > 32) {
self::setError('转写语言无效');
return false;
}
return \think\facade\Db::transaction(function () use ($params, $sessionId, $language) {
$record = self::resolveCallRecordForTranscription($params, true, true);
if (!$record) {
return false;
}
$existingSession = trim((string)($record['transcription_session_id'] ?? ''));
if ($existingSession !== '' && $existingSession !== $sessionId) {
self::setError('该通话记录已绑定其他转写会话');
return false;
}
if ($existingSession === '') {
$now = time();
$record->save([
'transcription_session_id' => $sessionId,
'transcription_language' => $language,
'transcription_status' => 'running',
'transcription_segment_count' => 0,
'transcript_text' => '',
'transcription_started_at' => $now,
'transcription_finished_at' => 0,
'update_time' => $now,
]);
}
return [
'call_record_id' => (int)$record['id'],
'transcription_session_id' => $sessionId,
'status' => (string)($record['transcription_status'] ?? 'running'),
];
});
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 幂等写入已完成的实时转写分段,并刷新通话记录文字字段
* @return array|false
*/
public static function upsertCallTranscriptSegments(array $params)
{
try {
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
$segments = $params['segments'] ?? null;
if ($sessionId === '' || mb_strlen($sessionId) > 128) {
self::setError('转写会话ID无效');
return false;
}
if (!is_array($segments) || count($segments) < 1 || count($segments) > 50) {
self::setError('转写分段数量必须为1到50条');
return false;
}
$normalized = [];
foreach ($segments as $segment) {
if (!is_array($segment)) {
self::setError('转写分段格式无效');
return false;
}
$segmentId = trim((string)($segment['segment_id'] ?? ''));
$text = trim((string)($segment['text'] ?? ''));
$speakerUserId = trim((string)($segment['speaker_user_id'] ?? ''));
$speakerRole = trim((string)($segment['speaker_role'] ?? 'unknown'));
if ($segmentId === '' || mb_strlen($segmentId) > 160) {
self::setError('转写分段ID无效');
return false;
}
if ($text === '' || mb_strlen($text) > 4000) {
self::setError('转写文字长度无效');
return false;
}
if (mb_strlen($speakerUserId) > 160) {
self::setError('说话人ID过长');
return false;
}
if (!in_array($speakerRole, ['doctor', 'patient', 'unknown'], true)) {
$speakerRole = 'unknown';
}
$normalized[] = [
'segment_id' => $segmentId,
'speaker_user_id' => $speakerUserId,
'speaker_role' => $speakerRole,
'timestamp_ms' => max(0, (int)($segment['timestamp'] ?? 0)),
'text' => $text,
];
}
$result = \think\facade\Db::transaction(function () use (
$params,
$sessionId,
$normalized
) {
$record = self::resolveCallRecordForTranscription($params, false, true);
if (!$record) {
return false;
}
if ((string)($record['transcription_session_id'] ?? '') !== $sessionId) {
self::setError('转写会话与通话记录不匹配');
return false;
}
if ((string)($record['transcription_status'] ?? '') !== 'running') {
self::setError('该通话转写已经结束');
return false;
}
$callRecordId = (int)$record['id'];
$now = time();
foreach ($normalized as $segment) {
$values = array_merge($segment, [
'call_record_id' => $callRecordId,
'transcription_session_id' => $sessionId,
'create_time' => $now,
'update_time' => $now,
]);
\think\facade\Db::name('tcm_call_transcript_segment')
->duplicate([
'speaker_user_id',
'speaker_role',
'timestamp_ms',
'text',
'update_time',
])
->insert($values);
}
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
$record->save([
'transcription_segment_count' => $snapshot['segment_count'],
'transcript_text' => $snapshot['transcript_text'],
'update_time' => $now,
]);
return [
'call_record_id' => $callRecordId,
'segment_count' => (int)$snapshot['segment_count'],
];
});
if ($result === false) {
return false;
}
return [
'call_record_id' => (int)$result['call_record_id'],
'transcription_session_id' => $sessionId,
'stored_segment_count' => (int)$result['segment_count'],
];
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 完成实时转写并将最终文字固化在通话记录上
* @return array|false
*/
public static function finishCallTranscription(array $params)
{
try {
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
$requestedStatus = strtolower(trim((string)($params['status'] ?? 'completed')));
$expectedCount = (int)($params['expected_segment_count'] ?? 0);
if ($sessionId === '' || mb_strlen($sessionId) > 128 || $expectedCount < 0) {
self::setError('转写完成参数无效');
return false;
}
if (!in_array($requestedStatus, ['completed', 'partial', 'failed'], true)) {
self::setError('转写完成状态无效');
return false;
}
return \think\facade\Db::transaction(function () use (
$params,
$sessionId,
$requestedStatus,
$expectedCount
) {
$record = self::resolveCallRecordForTranscription($params, false, true);
if (!$record) {
return false;
}
if ((string)($record['transcription_session_id'] ?? '') !== $sessionId) {
self::setError('转写会话与通话记录不匹配');
return false;
}
$existingStatus = (string)($record['transcription_status'] ?? '');
if ($existingStatus !== '' && $existingStatus !== 'running') {
return [
'call_record_id' => (int)$record['id'],
'transcription_session_id' => $sessionId,
'status' => $existingStatus,
'segment_count' => (int)($record['transcription_segment_count'] ?? 0),
];
}
$callRecordId = (int)$record['id'];
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
$actualCount = (int)$snapshot['segment_count'];
$finalStatus = $requestedStatus;
if ($requestedStatus === 'completed' && $actualCount < $expectedCount) {
$finalStatus = 'partial';
}
$now = time();
$record->save([
'transcription_status' => $finalStatus,
'transcription_segment_count' => $actualCount,
'transcript_text' => $snapshot['transcript_text'],
'transcription_finished_at' => $now,
'update_time' => $now,
]);
return [
'call_record_id' => $callRecordId,
'transcription_session_id' => $sessionId,
'status' => $finalStatus,
'segment_count' => $actualCount,
];
});
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
private static function resolveCallRecordForTranscription(
array $params,
bool $requireRunning,
bool $lock = false
)
{
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
$callRecordId = (int)($params['call_record_id'] ?? 0);
$adminId = (int)($params['admin_id'] ?? 0);
if ($diagnosisId <= 0 || $callRecordId <= 0 || $adminId <= 0) {
self::setError('通话转写身份参数无效');
return null;
}
$query = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
->where('diagnosis_id', $diagnosisId)
->where('caller_id', $adminId)
->where('caller_type', 'doctor');
if ($lock) {
$query->lock(true);
}
$record = $query->find();
if (!$record) {
self::setError('通话记录不存在或无权操作');
return null;
}
if ($requireRunning && (int)($record['status'] ?? 0) !== 1) {
self::setError('通话记录已经结束');
return null;
}
return $record;
}
/** @return array{segment_count:int,transcript_text:string} */
private static function buildCallTranscriptSnapshot(int $callRecordId, string $sessionId): array
{
$rows = \think\facade\Db::name('tcm_call_transcript_segment')
->where('call_record_id', $callRecordId)
->where('transcription_session_id', $sessionId)
->order('timestamp_ms asc, id asc')
->select()
->toArray();
$labels = ['doctor' => '医生', 'patient' => '患者', 'unknown' => '未知说话人'];
$lines = [];
foreach ($rows as $row) {
$text = trim((string)($row['text'] ?? ''));
if ($text === '') {
continue;
}
$role = (string)($row['speaker_role'] ?? 'unknown');
$lines[] = ($labels[$role] ?? $labels['unknown']) . '' . $text;
}
return [
'segment_count' => count($rows),
'transcript_text' => implode("\n", $lines),
];
}
private static function transcriptionStatusText(string $status): string
{
return [
'running' => '录音转写中',
'completed' => '文字已生成',
'partial' => '文字部分保存',
'failed' => '文字生成失败',
][$status] ?? '未生成文字';
}
/**
* @notes 将 TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制