Compare commits

...
1 Commits
Author SHA1 Message Date
Your Name 4ef3cef6a0 更新 2026-09-28 11:32:28 +08:00
2 changed files with 35 additions and 54 deletions
@@ -154,7 +154,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;开口=获客客户首次发起会话事件,按开口时间计入,同一员工的同一客户只计一次;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -449,7 +449,7 @@ const timeOptions = [
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '获客客户首次发起会话;按开口时间统计,同一员工的同一客户只计一次' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
@@ -10,7 +10,6 @@ use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\stats\PersonalYeji;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
@@ -18,7 +17,7 @@ use think\facade\Db;
/**
* 一诊「综合数据转化」。
*
* 自动指标复用 ConversionLogic;开口数来自个人业绩录入。所有筛选先与 DataScope
* 自动指标复用 ConversionLogic;开口数来自获客客户首次发起会话事件。所有筛选先与 DataScope
* 可见管理员集合取交集,HTTP 参数不能扩大当前账号的数据范围。
*/
class FirstVisitConversionLogic
@@ -94,7 +93,7 @@ class FirstVisitConversionLogic
$endDate,
$effectiveAdminIds,
array_fill_keys(array_keys($rowDeptIdSet), true),
self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel)
$selectedMediaChannel
);
$openDirect = $openCounts['dept'];
self::applyOpenCounts($rows, $openDirect, $openCounts['admin']);
@@ -159,8 +158,8 @@ class FirstVisitConversionLogic
'selected_media_channel_code' => $selectedMediaChannelCode,
'selected_media_channel_name' => $selectedMediaChannelName,
'open_count_source' => $selectedMediaChannelCode === ''
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
? '企微获客客户首次开口事件'
: '企微获客客户首次开口事件(按当前客户标签筛选渠道)',
'can_view_finance' => $canViewFinance,
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
@@ -713,7 +712,7 @@ class FirstVisitConversionLogic
/**
* @param int[]|null $effectiveAdminIds
* @param array<int,true> $rowDeptSet
* @param string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源
* @param array<string,mixed>|null $mediaChannel null=全部渠道
* @return array{dept:array<int,int>,admin:array<int,int>}
*/
private static function loadOpenCounts(
@@ -721,22 +720,42 @@ class FirstVisitConversionLogic
string $endDate,
?array $effectiveAdminIds,
array $rowDeptSet,
?array $mediaSources = null
?array $mediaChannel = null
): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) {
if ($effectiveAdminIds === [] || $rowDeptSet === []) {
return ['dept' => [], 'admin' => []];
}
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
$startTimestamp = strtotime($startDate . ' 00:00:00');
$endTimestamp = strtotime($endDate . ' 23:59:59');
$query = Db::name('qywx_customer_acquisition_event')
->alias('open_event')
->join(
'qywx_customer_acquisition_customer acquisition_customer',
'acquisition_customer.link_id = open_event.link_id'
. ' AND acquisition_customer.external_userid = open_event.external_userid'
. ' AND acquisition_customer.userid = open_event.userid'
)
->where('open_event.change_type', 'customer_start_chat')
->where('open_event.status', 1)
->whereBetween('open_event.event_time', [$startTimestamp, $endTimestamp])
->where('acquisition_customer.chat_status', 1)
->where('acquisition_customer.owner_admin_id', '>', 0);
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
$query->whereIn('acquisition_customer.owner_admin_id', $effectiveAdminIds);
}
if ($mediaSources !== null) {
$query->whereIn('media_source', $mediaSources);
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter(
$query,
'acquisition_customer.external_userid',
$mediaChannel,
'acquisition_customer.userid'
);
}
$rows = $query
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
->group('creator_id')
// 客户可从多个获客链接进入;按员工+客户去重,与加粉组合口径一致。
->fieldRaw('acquisition_customer.owner_admin_id AS creator_id, COUNT(DISTINCT acquisition_customer.external_userid) AS open_count')
->group('acquisition_customer.owner_admin_id')
->select()
->toArray();
if ($rows === []) {
@@ -871,44 +890,6 @@ class FirstVisitConversionLogic
return $sum;
}
/**
* 手工开口按 personal_yeji.media_source 保存;渠道筛选时仅匹配该渠道自身的稳定标识和名称。
* 不使用 source_group_name,避免同组多个渠道的开口数被重复计入每个渠道。
*
* @param array<string,mixed>|null $channel
* @return string[]|null
*/
private static function personalYejiMediaSources(string $channelCode, ?array $channel): ?array
{
if ($channelCode === '') {
return null;
}
if ($channel === null) {
return [];
}
$values = [
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
];
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $item) {
$values[] = $item;
}
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
$values
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
}
private static function canViewFinance(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {