Merge branch 'kpi-statis'

This commit is contained in:
2026-04-23 15:44:36 +08:00
19 changed files with 737 additions and 47 deletions
+2
View File
@@ -18,3 +18,5 @@ bin-release/
# information for Eclipse / Flash Builder.
/.idea
/.codex-tasks
/.trellis
+5
View File
@@ -59,3 +59,8 @@ export function accountCostEdit(params?: any) {
export function accountCostDetail(params?: any) {
return request.get({ url: '/finance.account_cost/detail', params })
}
// 账户消耗删除
export function accountCostDelete(params?: any) {
return request.post({ url: '/finance.account_cost/delete', params })
}
@@ -18,6 +18,22 @@
:disabled="mode === 'edit'"
/>
</el-form-item>
<el-form-item label="自媒体渠道" prop="media_channel_code">
<el-select
v-model="formData.media_channel_code"
placeholder="请选择自媒体渠道"
class="w-full"
filterable
:disabled="mode === 'edit'"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</el-select>
</el-form-item>
<el-form-item label="账户消耗" prop="amount">
<el-input-number
v-model="formData.amount"
@@ -48,6 +64,16 @@ import type { FormInstance } from 'element-plus'
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
import Popup from '@/components/popup/index.vue'
interface MediaChannelOption {
code: string
name: string
}
const props = defineProps<{
mediaChannelOptions: MediaChannelOption[]
defaultMediaChannelCode: string
}>()
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
@@ -58,6 +84,7 @@ const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗'
const formData = reactive({
id: '',
cost_date: '',
media_channel_code: '',
amount: 0,
remark: '',
})
@@ -70,6 +97,13 @@ const formRules = {
trigger: ['change'],
},
],
media_channel_code: [
{
required: true,
message: '请选择自媒体渠道',
trigger: ['change'],
},
],
amount: [
{
required: true,
@@ -82,6 +116,7 @@ const formRules = {
const resetForm = () => {
formData.id = ''
formData.cost_date = ''
formData.media_channel_code = props.defaultMediaChannelCode || ''
formData.amount = 0
formData.remark = ''
}
@@ -106,6 +141,7 @@ const open = (type: 'add' | 'edit' = 'add') => {
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.cost_date = data.cost_date ?? ''
formData.media_channel_code = data.media_channel_code ?? ''
formData.amount = Number(data.amount ?? 0)
formData.remark = data.remark ?? ''
}
+61 -3
View File
@@ -21,6 +21,22 @@
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="自媒体渠道">
<el-select
v-model="queryParams.media_channel_code"
placeholder="筛选全部渠道"
clearable
filterable
class="w-[220px]"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</el-select>
</el-form-item>
<el-form-item class="w-[280px]" label="备注/维护人">
<el-input
v-model="queryParams.remark"
@@ -48,6 +64,7 @@
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="日期" prop="cost_date" min-width="120" />
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
<el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
@@ -55,7 +72,7 @@
<el-table-column label="创建人" prop="creator_name" min-width="100" />
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['finance.account_cost/edit']"
@@ -65,6 +82,14 @@
>
编辑
</el-button>
<el-button
v-perms="['finance.account_cost/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -74,22 +99,36 @@
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
<edit-popup
v-if="showEdit"
ref="editRef"
:media-channel-options="mediaChannelOptions"
:default-media-channel-code="defaultMediaChannelCode"
@success="getLists"
@close="showEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="accountCostList">
import { accountCostLists } from '@/api/finance'
import { accountCostDelete, accountCostLists } from '@/api/finance'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
interface MediaChannelOption {
code: string
name: string
}
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
const showEdit = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
media_channel_code: '',
remark: '',
})
@@ -98,6 +137,18 @@ const { pager, getLists, resetPage } = usePaging({
params: queryParams,
})
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
const options = pager.extend.media_channel_options
if (!Array.isArray(options)) return []
return options.map((item: Record<string, any>) => ({
code: String(item.code || ''),
name: String(item.name || ''),
}))
})
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
const handleAdd = async () => {
showEdit.value = true
await nextTick()
@@ -111,9 +162,16 @@ const handleEdit = async (row: any) => {
editRef.value?.getDetail(row)
}
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除这条账户消耗记录吗?')
await accountCostDelete({ id })
getLists()
}
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_channel_code = ''
queryParams.remark = ''
resetPage()
}
+38 -2
View File
@@ -43,6 +43,24 @@
</el-select>
</el-form-item>
<el-form-item label="自媒体渠道">
<el-select
v-model="queryParams.media_channel_code"
placeholder="全部渠道"
clearable
filterable
style="width: 220px"
@change="handleMediaChannelChange"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
@@ -208,6 +226,11 @@ interface OptionItem {
name: string
}
interface MediaChannelOption {
code: string
name: string
}
interface MetricCard {
key: string
label: string
@@ -227,6 +250,7 @@ const deptTreeOptions = ref<any[]>([])
const deptOptions = ref<OptionItem[]>([])
const assistantOptions = ref<OptionItem[]>([])
const doctorOptions = ref<OptionItem[]>([])
const mediaChannelOptions = ref<MediaChannelOption[]>([])
const filtersLoaded = ref(false)
const overview = reactive<Record<string, any>>({
dimension: 'dept',
@@ -250,6 +274,7 @@ const queryParams = reactive({
dept_id: undefined as number | undefined,
assistant_id: undefined as number | undefined,
doctor_id: undefined as number | undefined,
media_channel_code: '',
time_type: 'today',
start_date: '',
end_date: ''
@@ -367,8 +392,8 @@ const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
const rankingChartData = computed(() => {
const ranking = overview.charts?.ranking || {}
const names = Array.isArray(ranking.names) ? ranking.names : []
const normalize = (values: unknown[]) => names.map((_, index) => Number(values?.[index] ?? 0))
const names: unknown[] = Array.isArray(ranking.names) ? ranking.names : []
const normalize = (values: unknown[]) => names.map((_: unknown, index: number) => Number(values?.[index] ?? 0))
return {
names,
@@ -505,6 +530,7 @@ const applyFilterOptions = (filters?: Record<string, any>) => {
const departments = Array.isArray(filters.departments) ? filters.departments : []
const assistants = Array.isArray(filters.assistants) ? filters.assistants : []
const doctors = Array.isArray(filters.doctors) ? filters.doctors : []
const mediaChannels = Array.isArray(filters.media_channels) ? filters.media_channels : []
deptTreeOptions.value = departments
deptOptions.value = flattenDepts(departments)
@@ -516,6 +542,10 @@ const applyFilterOptions = (filters?: Record<string, any>) => {
id: Number(item.id),
name: item.name,
}))
mediaChannelOptions.value = mediaChannels.map((item: any) => ({
code: String(item.code || ''),
name: String(item.name || ''),
}))
}
const fetchOverview = async () => {
@@ -540,6 +570,11 @@ const handleEntityChange = () => {
fetchOverview()
}
const handleMediaChannelChange = () => {
pager.page = 1
fetchOverview()
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
@@ -565,6 +600,7 @@ const handleReset = () => {
queryParams.dept_id = undefined
queryParams.assistant_id = undefined
queryParams.doctor_id = undefined
queryParams.media_channel_code = ''
queryParams.time_type = 'today'
dateRange.value = []
pager.page = 1
File diff suppressed because one or more lines are too long
@@ -44,4 +44,15 @@ class AccountCostController extends BaseAdminController
return $this->data(AccountCostLogic::detail((int) $params['id']));
}
public function delete()
{
$params = (new AccountCostValidate())->post()->goCheck('delete');
$result = AccountCostLogic::delete((int) $params['id']);
if ($result === false) {
return $this->fail(AccountCostLogic::getError());
}
return $this->success('删除成功', [], 1, 1);
}
}
@@ -8,6 +8,7 @@ use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\finance\AccountCost;
use app\common\service\qywx\MediaChannelService;
class AccountCostLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
@@ -30,6 +31,10 @@ class AccountCostLists extends BaseAdminDataLists implements ListsSearchInterfac
$query->where('cost_date', '<=', $this->params['end_date']);
}
if (!empty($this->params['media_channel_code'])) {
$query->where('media_channel_code', trim((string) $this->params['media_channel_code']));
}
return $query;
}
@@ -49,11 +54,11 @@ class AccountCostLists extends BaseAdminDataLists implements ListsSearchInterfac
public function extend(): array
{
$baseQuery = $this->baseQuery();
return [
'total_amount' => round((float) $baseQuery->sum('amount'), 2),
'days_count' => (int) $baseQuery->count(),
'total_amount' => round((float) $this->baseQuery()->sum('amount'), 2),
'days_count' => (int) $this->baseQuery()->distinct(true)->count('cost_date'),
'media_channel_options' => MediaChannelService::getOptions(),
'default_media_channel_code' => MediaChannelService::getDefaultCode(),
];
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\finance;
use app\common\logic\BaseLogic;
use app\common\model\finance\AccountCost;
use app\common\service\qywx\MediaChannelService;
class AccountCostLogic extends BaseLogic
{
@@ -13,14 +14,19 @@ class AccountCostLogic extends BaseLogic
{
try {
$costDate = (string) $params['cost_date'];
if (AccountCost::where('cost_date', $costDate)->count() > 0) {
self::setError('该日期的账户消耗已存在,请直接编辑');
$mediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
$mediaChannelName = MediaChannelService::getNameByCode($mediaChannelCode);
if (AccountCost::where('cost_date', $costDate)->where('media_channel_code', $mediaChannelCode)->count() > 0) {
self::setError('该日期下所选渠道的账户消耗已存在,请直接编辑');
return false;
}
AccountCost::create([
'cost_date' => $costDate,
'media_channel_code' => $mediaChannelCode,
'media_channel_name' => $mediaChannelName,
'amount' => round((float) $params['amount'], 2),
'remark' => (string) ($params['remark'] ?? ''),
'creator_id' => $adminId,
@@ -65,4 +71,24 @@ class AccountCostLogic extends BaseLogic
{
return AccountCost::findOrEmpty($id)->toArray();
}
public static function delete(int $id): bool
{
try {
$model = AccountCost::find($id);
if (!$model) {
self::setError('记录不存在');
return false;
}
$model->delete();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
class ConversionLogic
@@ -20,6 +21,9 @@ class ConversionLogic
{
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
@@ -52,23 +56,24 @@ class ConversionLogic
}
$adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateAccountCostStats($entities, $startDate, $endDate);
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate, $mediaChannel);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
$globalAccountCost = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode);
if ($dimension === 'dept') {
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
$entities,
isset($params['dept_id']) ? (int)$params['dept_id'] : 0,
$pageNo,
$pageSize
$pageSize,
$filterEmptyEntities
);
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows),
'summary' => self::buildSummary($allRows, $globalAccountCost),
'charts' => self::buildCharts($chartRows),
'lists' => $pagedRows,
'count' => count($allRows),
@@ -77,7 +82,7 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows),
'summary' => self::buildSummary($allRows, $globalAccountCost),
'charts' => self::buildCharts($chartRows),
],
];
@@ -88,14 +93,14 @@ class ConversionLogic
return $result;
}
$rows = self::finalizeRows($entities);
$rows = self::finalizeRows($entities, $filterEmptyEntities);
$count = count($rows);
$offset = ($pageNo - 1) * $pageSize;
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows),
'summary' => self::buildSummary($rows, $globalAccountCost),
'charts' => self::buildCharts($rows),
'lists' => array_slice($rows, $offset, $pageSize),
'count' => $count,
@@ -104,7 +109,7 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows),
'summary' => self::buildSummary($rows, $globalAccountCost),
'charts' => self::buildCharts($rows),
],
];
@@ -124,6 +129,7 @@ class ConversionLogic
'departments' => DeptLogic::getAllData(),
'assistants' => DiagnosisLogic::getAssistants(),
'doctors' => DiagnosisLogic::getDoctors(),
'media_channels' => MediaChannelService::getOptions(),
];
}
@@ -351,6 +357,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
*/
private static function hydrateFanStats(
array &$entities,
@@ -358,17 +365,23 @@ class ConversionLogic
array $entityIds,
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp
int $endTimestamp,
?array $mediaChannel
): void {
$rows = Db::name('qywx_external_contact_event')
$query = Db::name('qywx_external_contact_event')
->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('e.user_id, a.id AS admin_id, COUNT(*) AS add_fans_count')
->group('e.user_id, a.id')
->select()
->toArray();
->group('e.user_id, a.id');
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
@@ -407,6 +420,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
*/
private static function hydrateAppointmentStats(
array &$entities,
@@ -414,19 +428,25 @@ class ConversionLogic
array $entityIds,
array $adminToDeptIds,
string $startDate,
string $endDate
string $endDate,
?array $mediaChannel
): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$rows = Db::name('doctor_appointment')
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group("{$sourceExpr}, a.patient_id")
->select()
->toArray();
->group("{$sourceExpr}, a.patient_id");
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$diagnosisId = (int)($row['diagnosis_id'] ?? 0);
@@ -458,6 +478,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel
*/
private static function hydrateOrderAndAmountStats(
array &$entities,
@@ -465,19 +486,26 @@ class ConversionLogic
array $entityIds,
array $adminToDeptIds,
int $startTimestamp,
int $endTimestamp
int $endTimestamp,
?array $mediaChannel
): void {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$rows = Db::name('tcm_prescription_order')
$query = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.payment_slip_audit_status', 1)
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw("{$sourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount")
->group($sourceExpr)
->select()
->toArray();
->group($sourceExpr);
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = dg.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
@@ -499,22 +527,29 @@ class ConversionLogic
/**
* 账户消耗:来源于独立维护表 zyt_account_cost,为全局日维度数据。
* 由于当前没有部门/医助/医生拆分来源,只在汇总层使用,不向各明细行分摊。
* 由于当前没有部门/医助/医生拆分来源,只在当前筛选口径的汇总层使用,不向各明细行分摊。
*
* @param array<int, array<string, mixed>> $entities
*/
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate): void
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate, string $mediaChannelCode): float
{
$totalAmount = round((float) Db::name('account_cost')
$query = Db::name('account_cost')
->where('cost_date', '>=', $startDate)
->where('cost_date', '<=', $endDate)
->sum('amount'), 2);
->where('cost_date', '<=', $endDate);
if ($mediaChannelCode !== '') {
$query->where('media_channel_code', $mediaChannelCode);
}
$totalAmount = round((float) $query->sum('amount'), 2);
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = $totalAmount;
}
unset($entity);
return $totalAmount;
}
@@ -546,7 +581,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $entities
* @return array<int, array<string, mixed>>
*/
private static function finalizeRows(array $entities): array
private static function finalizeRows(array $entities, bool $excludeEmpty = false): array
{
$rows = [];
$globalAccountCost = 0.0;
@@ -578,6 +613,10 @@ class ConversionLogic
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
if ($excludeEmpty && !self::hasBusinessMetrics($entity)) {
continue;
}
$rows[] = $entity;
}
@@ -608,7 +647,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $entities
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: array<int, array<string, mixed>>}
*/
private static function buildDeptTreeRows(array $entities, int $selectedDeptId, int $pageNo, int $pageSize): array
private static function buildDeptTreeRows(array $entities, int $selectedDeptId, int $pageNo, int $pageSize, bool $excludeEmpty = false): array
{
$childrenByPid = [];
foreach ($entities as $entity) {
@@ -668,6 +707,12 @@ class ConversionLogic
});
$rootRows = array_map(static fn (int $deptId): array => self::finalizeDeptNode($buildNode($deptId)), $rootIds);
if ($excludeEmpty) {
$rootRows = array_values(array_filter(array_map(
static fn (array $row): ?array => self::pruneDeptNode($row),
$rootRows
)));
}
$realRootRows = array_values(array_filter($rootRows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
$virtualRoot = count($realRootRows) === 1 && !empty($realRootRows[0]['children']) ? $realRootRows[0] : null;
@@ -713,6 +758,31 @@ class ConversionLogic
return null;
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>|null
*/
private static function pruneDeptNode(array $node): ?array
{
$children = [];
foreach ($node['children'] ?? [] as $child) {
if (!is_array($child)) {
continue;
}
$pruned = self::pruneDeptNode($child);
if ($pruned !== null) {
$children[] = $pruned;
}
}
$node['children'] = $children;
if ($children !== []) {
return $node;
}
return self::hasBusinessMetrics($node) ? $node : null;
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
@@ -758,7 +828,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function buildSummary(array $rows): array
private static function buildSummary(array $rows, float $forcedAccountCost = 0.0): array
{
$summary = [
'add_fans_count' => 0,
@@ -787,7 +857,7 @@ class ConversionLogic
$globalAccountCost = max($globalAccountCost, round((float)($row['_global_account_cost'] ?? 0), 2));
}
$summary['account_cost'] = $globalAccountCost;
$summary['account_cost'] = max($globalAccountCost, round($forcedAccountCost, 2));
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
@@ -802,6 +872,20 @@ class ConversionLogic
return $summary;
}
/**
* @param array<string, mixed> $row
*/
private static function hasBusinessMetrics(array $row): bool
{
return (int)($row['add_fans_count'] ?? 0) > 0
|| (int)($row['paid_appointment_count'] ?? 0) > 0
|| (int)($row['free_appointment_count'] ?? 0) > 0
|| (int)($row['appointment_total_count'] ?? 0) > 0
|| (int)($row['interview_count'] ?? 0) > 0
|| (int)($row['completed_order_count'] ?? 0) > 0
|| (float)($row['completed_order_amount'] ?? 0) > 0;
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\validate\finance;
use app\common\model\finance\AccountCost;
use app\common\service\qywx\MediaChannelService;
use app\common\validate\BaseValidate;
class AccountCostValidate extends BaseValidate
@@ -12,6 +13,7 @@ class AccountCostValidate extends BaseValidate
protected $rule = [
'id' => 'require|checkExists',
'cost_date' => 'require|dateFormat:Y-m-d',
'media_channel_code' => 'require|checkMediaChannelCode',
'amount' => 'require|float|egt:0',
'remark' => 'max:255',
];
@@ -20,6 +22,7 @@ class AccountCostValidate extends BaseValidate
'id.require' => '参数缺失',
'cost_date.require' => '请选择日期',
'cost_date.dateFormat' => '日期格式错误',
'media_channel_code.require' => '请选择自媒体渠道',
'amount.require' => '请输入账户消耗金额',
'amount.float' => '账户消耗金额格式错误',
'amount.egt' => '账户消耗金额不能小于0',
@@ -41,6 +44,11 @@ class AccountCostValidate extends BaseValidate
return $this->only(['id']);
}
public function sceneDelete()
{
return $this->only(['id']);
}
public function checkExists($value)
{
$model = AccountCost::find($value);
@@ -50,4 +58,13 @@ class AccountCostValidate extends BaseValidate
return true;
}
public function checkMediaChannelCode($value)
{
if (!MediaChannelService::isValidCode((string) $value)) {
return '自媒体渠道不合法';
}
return true;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\MediaChannelService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class QywxScanMediaChannel extends Command
{
protected function configure()
{
$this->setName('qywx:scan-media-channel')
->addOption('batch', 'b', \think\console\input\Option::VALUE_OPTIONAL, '每批扫描客户数', 200)
->setDescription('扫描企微客户 follow_users 并记录渠道标签源');
}
protected function execute(Input $input, Output $output)
{
$batchSize = max(1, (int) $input->getOption('batch'));
$output->writeln('开始扫描企微客户渠道标签...');
$result = MediaChannelService::scanFromContacts($batchSize);
$output->writeln(sprintf(
'扫描完成:客户 %d,发现渠道标签 %d,写入/更新 %d。',
(int) ($result['scanned_contacts'] ?? 0),
(int) ($result['discovered_tags'] ?? 0),
(int) ($result['inserted_or_updated'] ?? 0)
));
return 0;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace app\common\model;
class QywxMediaChannel extends BaseModel
{
protected $name = 'qywx_media_channel';
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $dateFormat = false;
}
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use app\common\model\QywxExternalContact;
use app\common\model\QywxMediaChannel;
use think\db\Query;
use think\facade\Db;
class MediaChannelService
{
/**
* @return array<int, array{code: string, name: string}>
*/
public static function getOptions(): array
{
$rows = QywxMediaChannel::where('status', 1)
->field('channel_code, channel_name')
->order('channel_name asc, id asc')
->select()
->toArray();
return array_map(static fn (array $row): array => [
'code' => (string) ($row['channel_code'] ?? ''),
'name' => (string) ($row['channel_name'] ?? ''),
], $rows);
}
public static function getDefaultCode(): string
{
return (string) QywxMediaChannel::where('status', 1)
->order('id asc')
->value('channel_code');
}
public static function isValidCode(string $channelCode): bool
{
$channelCode = trim($channelCode);
if ($channelCode === '') {
return false;
}
return QywxMediaChannel::where('status', 1)
->where('channel_code', $channelCode)
->count() > 0;
}
public static function normalizeStatsCode(string $channelCode): string
{
return self::isValidCode($channelCode) ? trim($channelCode) : '';
}
/**
* @return array<string, mixed>|null
*/
public static function getChannelByCode(string $channelCode): ?array
{
$channelCode = trim($channelCode);
if ($channelCode === '') {
return null;
}
$row = QywxMediaChannel::where('status', 1)
->where('channel_code', $channelCode)
->find();
return $row ? $row->toArray() : null;
}
public static function getNameByCode(string $channelCode): string
{
$channel = self::getChannelByCode($channelCode);
return (string) ($channel['channel_name'] ?? '');
}
/**
* @param array<string, mixed>|null $channel
*/
public static function applyFollowUsersChannelFilter(Query $query, string $field, ?array $channel): void
{
if ($channel === null) {
return;
}
$patterns = self::buildLikePatterns($channel);
if ($patterns === []) {
$query->whereRaw('1 = 0');
return;
}
$segments = [];
$bindings = [];
foreach ($patterns as $pattern) {
$segments[] = $field . ' LIKE ?';
$bindings[] = $pattern;
}
$query->whereRaw('(' . implode(' OR ', $segments) . ')', $bindings);
}
/**
* @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int}
*/
public static function scanFromContacts(int $batchSize = 200): array
{
$lastId = 0;
$scannedContacts = 0;
$discoveredTags = [];
$upserted = 0;
$now = time();
while (true) {
$rows = QywxExternalContact::where('id', '>', $lastId)
->field('id, follow_users')
->order('id asc')
->limit($batchSize)
->select()
->toArray();
if ($rows === []) {
break;
}
foreach ($rows as $row) {
$lastId = (int) ($row['id'] ?? 0);
if ($lastId <= 0) {
continue;
}
$scannedContacts++;
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
$followUsers = is_array($followUsers) ? $followUsers : [];
foreach (self::extractTagsFromFollowUsers($followUsers) as $tag) {
$tagKey = self::buildTagUniqKey($tag['source_tag_id'], $tag['source_tag_name']);
if ($tagKey === '' || isset($discoveredTags[$tagKey])) {
continue;
}
$discoveredTags[$tagKey] = true;
$channelCode = self::buildChannelCode($tag['source_tag_id'], $tag['source_tag_name']);
$channelName = $tag['source_tag_name'] !== '' ? $tag['source_tag_name'] : $tag['source_tag_id'];
$rowData = [
'channel_code' => $channelCode,
'channel_name' => $channelName,
'source_tag_id' => $tag['source_tag_id'],
'source_tag_name' => $tag['source_tag_name'],
'source_group_name' => $tag['source_group_name'],
'tag_uniq_key' => $tagKey,
'status' => 1,
'last_seen_time' => $now,
'create_time' => $now,
'update_time' => $now,
];
Db::name('qywx_media_channel')->duplicate([
'channel_name',
'source_tag_id',
'source_tag_name',
'source_group_name',
'status',
'last_seen_time',
'update_time',
])->insert($rowData);
$upserted++;
}
}
}
return [
'scanned_contacts' => $scannedContacts,
'discovered_tags' => count($discoveredTags),
'inserted_or_updated' => $upserted,
];
}
/**
* @param array<string, mixed> $channel
* @return string[]
*/
private static function buildLikePatterns(array $channel): array
{
$patterns = [];
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
$tagName = trim((string) ($channel['source_tag_name'] ?? ''));
if ($tagId !== '') {
$escapedTagId = addcslashes($tagId, '%_\\');
$patterns[] = '%"tag_id":"' . $escapedTagId . '"%';
$patterns[] = '%"id":"' . $escapedTagId . '"%';
}
if ($tagName !== '') {
$escapedTagName = addcslashes($tagName, '%_\\');
$patterns[] = '%"name":"' . $escapedTagName . '"%';
$patterns[] = '%"tag_name":"' . $escapedTagName . '"%';
}
return array_values(array_unique($patterns));
}
/**
* @param array<int, mixed> $followUsers
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
*/
private static function extractTagsFromFollowUsers(array $followUsers): array
{
$tags = [];
foreach ($followUsers as $followUser) {
if (!is_array($followUser)) {
continue;
}
$rawTags = $followUser['tags'] ?? [];
if (!is_array($rawTags)) {
continue;
}
foreach ($rawTags as $tag) {
if (!is_array($tag)) {
continue;
}
$tagId = trim((string) ($tag['tag_id'] ?? $tag['id'] ?? ''));
$tagName = trim((string) ($tag['name'] ?? $tag['tag_name'] ?? ''));
$groupName = trim((string) ($tag['group_name'] ?? ''));
$uniqKey = self::buildTagUniqKey($tagId, $tagName);
if ($uniqKey === '') {
continue;
}
$tags[$uniqKey] = [
'source_tag_id' => $tagId,
'source_tag_name' => $tagName,
'source_group_name' => $groupName,
];
}
}
return array_values($tags);
}
private static function buildTagUniqKey(string $tagId, string $tagName): string
{
$tagId = trim($tagId);
$tagName = trim($tagName);
if ($tagId !== '') {
return 'tag_id:' . $tagId;
}
if ($tagName !== '') {
return 'tag_name:' . md5(mb_strtolower($tagName, 'UTF-8'));
}
return '';
}
private static function buildChannelCode(string $tagId, string $tagName): string
{
$tagId = trim($tagId);
if ($tagId !== '') {
return 'tag_' . preg_replace('/[^A-Za-z0-9_\-]/', '_', $tagId);
}
$normalizedName = trim(mb_strtolower($tagName, 'UTF-8'));
return 'tagname_' . substr(md5($normalizedName), 0, 16);
}
}
+2
View File
@@ -24,6 +24,8 @@ return [
'express:sync' => 'app\\command\\SyncTrackingNumbers',
// 企业微信客户同步
'qywx:sync-customer' => 'app\\command\\QywxSyncCustomer',
// 扫描企微客户渠道标签
'qywx:scan-media-channel' => 'app\\command\\QywxScanMediaChannel',
// 企业微信会话内容存档同步(需开通会话存档 License 并配置 msgaudit_* 相关项 + 动态库)
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS `zyt_qywx_media_channel` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`channel_code` varchar(80) NOT NULL DEFAULT '' COMMENT '渠道编码:由 tag_id 或 tag_name 生成',
`channel_name` varchar(100) NOT NULL DEFAULT '' COMMENT '渠道名称(展示名)',
`source_tag_id` varchar(64) NOT NULL DEFAULT '' COMMENT '企微标签ID',
`source_tag_name` varchar(100) NOT NULL DEFAULT '' COMMENT '企微标签名',
`source_group_name` varchar(100) NOT NULL DEFAULT '' COMMENT '企微标签组名',
`tag_uniq_key` varchar(120) NOT NULL DEFAULT '' COMMENT '标签唯一键:优先 tag_id,否则 tag_name 哈希',
`status` tinyint(1) unsigned NOT NULL DEFAULT 1 COMMENT '状态:1=启用',
`last_seen_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '最近扫描命中时间',
`create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`update_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_code` (`channel_code`),
UNIQUE KEY `uk_tag_uniq_key` (`tag_uniq_key`),
KEY `idx_status` (`status`),
KEY `idx_last_seen_time` (`last_seen_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微渠道标签源表';
ALTER TABLE `zyt_account_cost`
ADD COLUMN `media_channel_code` varchar(80) NOT NULL DEFAULT '' COMMENT '渠道编码' AFTER `cost_date`,
ADD COLUMN `media_channel_name` varchar(100) NOT NULL DEFAULT '' COMMENT '渠道名称' AFTER `media_channel_code`;
ALTER TABLE `zyt_account_cost`
DROP INDEX `uk_cost_date`,
ADD UNIQUE KEY `uk_cost_date_channel` (`cost_date`, `media_channel_code`),
ADD KEY `idx_cost_date_channel` (`cost_date`, `media_channel_code`);
@@ -12,4 +12,5 @@ INSERT INTO `zyt_system_menu`
VALUES
(@account_cost_menu_id, 'A', '新增', '', 1, 'finance.account_cost/add', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(@account_cost_menu_id, 'A', '编辑', '', 2, 'finance.account_cost/edit', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(@account_cost_menu_id, 'A', '详情', '', 3, 'finance.account_cost/detail', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
(@account_cost_menu_id, 'A', '详情', '', 3, 'finance.account_cost/detail', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(@account_cost_menu_id, 'A', '删除', '', 4, 'finance.account_cost/delete', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
@@ -0,0 +1,25 @@
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
parent.id,
'A',
'删除',
'',
4,
'finance.account_cost/delete',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM `zyt_system_menu` parent
WHERE parent.perms = 'finance.account_cost/lists'
AND NOT EXISTS (
SELECT 1
FROM `zyt_system_menu` child
WHERE child.pid = parent.id
AND child.perms = 'finance.account_cost/delete'
);
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS `zyt_qywx_media_channel` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`channel_code` varchar(80) NOT NULL DEFAULT '' COMMENT '渠道编码:由 tag_id 或 tag_name 生成',
`channel_name` varchar(100) NOT NULL DEFAULT '' COMMENT '渠道名称(展示名)',
`source_tag_id` varchar(64) NOT NULL DEFAULT '' COMMENT '企微标签ID',
`source_tag_name` varchar(100) NOT NULL DEFAULT '' COMMENT '企微标签名',
`source_group_name` varchar(100) NOT NULL DEFAULT '' COMMENT '企微标签组名',
`tag_uniq_key` varchar(120) NOT NULL DEFAULT '' COMMENT '标签唯一键:优先 tag_id,否则 tag_name 哈希',
`status` tinyint(1) unsigned NOT NULL DEFAULT 1 COMMENT '状态:1=启用',
`last_seen_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '最近扫描命中时间',
`create_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`update_time` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel_code` (`channel_code`),
UNIQUE KEY `uk_tag_uniq_key` (`tag_uniq_key`),
KEY `idx_status` (`status`),
KEY `idx_last_seen_time` (`last_seen_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微渠道标签源表';
ALTER TABLE `zyt_account_cost`
ADD COLUMN `media_channel_code` varchar(80) NOT NULL DEFAULT '' COMMENT '渠道编码' AFTER `cost_date`,
ADD COLUMN `media_channel_name` varchar(100) NOT NULL DEFAULT '' COMMENT '渠道名称' AFTER `media_channel_code`;
ALTER TABLE `zyt_account_cost`
DROP INDEX `uk_cost_date`,
ADD UNIQUE KEY `uk_cost_date_channel` (`cost_date`, `media_channel_code`),
ADD KEY `idx_cost_date_channel` (`cost_date`, `media_channel_code`);
-- 初始化渠道源
-- php think qywx:scan-media-channel