This commit is contained in:
2026-04-21 17:24:51 +08:00
parent d47ef8d680
commit 4f45ecbd63
13 changed files with 675 additions and 105 deletions
+20
View File
@@ -39,3 +39,23 @@ export function refundLog(params?: any) {
export function refundStat(params?: any) { export function refundStat(params?: any) {
return request.get({ url: '/finance.refund/stat', params }) return request.get({ url: '/finance.refund/stat', params })
} }
// 账户消耗列表
export function accountCostLists(params?: any) {
return request.get({ url: '/finance.account_cost/lists', params })
}
// 账户消耗新增
export function accountCostAdd(params?: any) {
return request.post({ url: '/finance.account_cost/add', params })
}
// 账户消耗编辑
export function accountCostEdit(params?: any) {
return request.post({ url: '/finance.account_cost/edit', params })
}
// 账户消耗详情
export function accountCostDetail(params?: any) {
return request.get({ url: '/finance.account_cost/detail', params })
}
@@ -0,0 +1,127 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="560px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="100px" :rules="formRules">
<el-form-item label="日期" prop="cost_date">
<el-date-picker
v-model="formData.cost_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
/>
</el-form-item>
<el-form-item label="账户消耗" prop="amount">
<el-input-number
v-model="formData.amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 4, maxRows: 6 }"
maxlength="255"
show-word-limit
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
import Popup from '@/components/popup/index.vue'
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
const formData = reactive({
id: '',
cost_date: '',
amount: 0,
remark: '',
})
const formRules = {
cost_date: [
{
required: true,
message: '请选择日期',
trigger: ['change'],
},
],
amount: [
{
required: true,
message: '请输入账户消耗金额',
trigger: ['blur', 'change'],
},
],
}
const resetForm = () => {
formData.id = ''
formData.cost_date = ''
formData.amount = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await accountCostEdit(formData)
} else {
await accountCostAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.cost_date = data.cost_date ?? ''
formData.amount = Number(data.amount ?? 0)
formData.remark = data.remark ?? ''
}
const getDetail = async (row: Record<string, any>) => {
const data = await accountCostDetail({ id: row.id })
setFormData(data)
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
getDetail,
})
</script>
@@ -0,0 +1,122 @@
<template>
<div class="account-cost-list">
<el-card class="!border-none mb-4" shadow="never">
<div class="flex flex-wrap">
<div class="w-1/2 md:w-1/3">
<div class="leading-10">当前筛选天数</div>
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
</div>
<div class="w-1/2 md:w-1/3">
<div class="leading-10">累计账户消耗 ()</div>
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
</div>
</div>
</el-card>
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="备注/维护人">
<el-input
v-model="queryParams.remark"
placeholder="备注 / 创建人 / 修改人"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button v-perms="['finance.account_cost/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</div>
<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="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="260" show-overflow-tooltip />
<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">
<template #default="{ row }">
<el-button
v-perms="['finance.account_cost/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
<script lang="ts" setup name="accountCostList">
import { accountCostLists } from '@/api/finance'
import { usePaging } from '@/hooks/usePaging'
import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
const showEdit = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
remark: '',
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: accountCostLists,
params: queryParams,
})
const handleAdd = async () => {
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
const handleEdit = async (row: any) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.getDetail(row)
}
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.remark = ''
resetPage()
}
getLists()
</script>
+43 -5
View File
@@ -326,6 +326,7 @@ const summaryCards: MetricCard[] = [
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' }, { key: 'completed_order_count', label: '接诊诊单', type: 'count' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money' }, { key: 'completed_order_amount', label: '诊单金额', type: 'money' },
{ key: 'account_cost', label: '账户消耗', type: 'money' }, { key: 'account_cost', label: '账户消耗', type: 'money' },
{ key: 'cash_cost', label: '现金成本', type: 'money' },
{ key: 'roi', label: 'ROI', type: 'ratio' } { key: 'roi', label: 'ROI', type: 'ratio' }
] ]
@@ -346,7 +347,6 @@ const tableColumns: MetricColumn[] = [
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 130 }, { key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 130 },
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', placeholder: true, minWidth: 120 }, { key: 'open_receive_rate', label: '开口接诊率', type: 'percent', placeholder: true, minWidth: 120 },
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 110 }, { key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 110 },
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 120 },
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 110 }, { key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 110 },
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 } { key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
] ]
@@ -421,12 +421,31 @@ const rankingChartOption = computed(() => ({
const amountPieOption = computed(() => ({ const amountPieOption = computed(() => ({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
legend: { bottom: 0 }, legend: {
bottom: 0,
type: 'scroll',
pageIconColor: '#409eff',
pageTextStyle: {
color: '#909399',
},
},
series: [ series: [
{ {
name: '诊单金额', name: '诊单金额',
type: 'pie', type: 'pie',
radius: ['42%', '72%'], radius: ['42%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
minAngle: 3,
label: {
show: true,
formatter: '{b}',
overflow: 'truncate',
width: 90,
},
labelLayout: {
hideOverlap: true,
},
data: overview.charts.amount_share data: overview.charts.amount_share
} }
] ]
@@ -434,12 +453,31 @@ const amountPieOption = computed(() => ({
const fanPieOption = computed(() => ({ const fanPieOption = computed(() => ({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
legend: { bottom: 0 }, legend: {
bottom: 0,
type: 'scroll',
pageIconColor: '#409eff',
pageTextStyle: {
color: '#909399',
},
},
series: [ series: [
{ {
name: '加粉数', name: '加粉数',
type: 'pie', type: 'pie',
radius: ['42%', '72%'], radius: ['42%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
minAngle: 3,
label: {
show: true,
formatter: '{b}',
overflow: 'truncate',
width: 90,
},
labelLayout: {
hideOverlap: true,
},
data: overview.charts.fan_share data: overview.charts.fan_share
} }
] ]
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\finance;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\finance\AccountCostLists;
use app\adminapi\logic\finance\AccountCostLogic;
use app\adminapi\validate\finance\AccountCostValidate;
class AccountCostController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new AccountCostLists());
}
public function add()
{
$params = (new AccountCostValidate())->post()->goCheck('add');
$result = AccountCostLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
if ($result === false) {
return $this->fail(AccountCostLogic::getError());
}
return $this->success('添加成功', [], 1, 1);
}
public function edit()
{
$params = (new AccountCostValidate())->post()->goCheck('edit');
$result = AccountCostLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
if ($result === false) {
return $this->fail(AccountCostLogic::getError());
}
return $this->success('编辑成功', [], 1, 1);
}
public function detail()
{
$params = (new AccountCostValidate())->goCheck('detail');
return $this->data(AccountCostLogic::detail((int) $params['id']));
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\finance;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\finance\AccountCost;
class AccountCostLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
public function setSearch(): array
{
return [
'%like%' => ['remark', 'creator_name', 'updater_name'],
];
}
private function baseQuery()
{
$query = AccountCost::where($this->searchWhere);
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$query->whereBetween('cost_date', [$this->params['start_date'], $this->params['end_date']]);
} elseif (!empty($this->params['start_date'])) {
$query->where('cost_date', '>=', $this->params['start_date']);
} elseif (!empty($this->params['end_date'])) {
$query->where('cost_date', '<=', $this->params['end_date']);
}
return $query;
}
public function lists(): array
{
return $this->baseQuery()
->order(['cost_date' => 'desc', 'id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
}
public function count(): int
{
return (int) $this->baseQuery()->count();
}
public function extend(): array
{
$baseQuery = $this->baseQuery();
return [
'total_amount' => round((float) $baseQuery->sum('amount'), 2),
'days_count' => (int) $baseQuery->count(),
];
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\finance;
use app\common\logic\BaseLogic;
use app\common\model\finance\AccountCost;
class AccountCostLogic extends BaseLogic
{
public static function add(array $params, int $adminId, string $adminName): bool
{
try {
$costDate = (string) $params['cost_date'];
if (AccountCost::where('cost_date', $costDate)->count() > 0) {
self::setError('该日期的账户消耗已存在,请直接编辑');
return false;
}
AccountCost::create([
'cost_date' => $costDate,
'amount' => round((float) $params['amount'], 2),
'remark' => (string) ($params['remark'] ?? ''),
'creator_id' => $adminId,
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
]);
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function edit(array $params, int $adminId, string $adminName): bool
{
try {
$model = AccountCost::find($params['id']);
if (!$model) {
self::setError('记录不存在');
return false;
}
$model->amount = round((float) $params['amount'], 2);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
$model->updater_name = $adminName;
$model->save();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function detail(int $id): array
{
return AccountCost::findOrEmpty($id)->toArray();
}
}
@@ -6,7 +6,6 @@ namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic; use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\DiagnosisLogic; use app\adminapi\logic\tcm\DiagnosisLogic;
use think\facade\Cache;
use think\facade\Db; use think\facade\Db;
class ConversionLogic class ConversionLogic
@@ -17,18 +16,6 @@ class ConversionLogic
public static function overview(array $params = []): array public static function overview(array $params = []): array
{ {
$includeFilters = (int)($params['include_filters'] ?? 0) === 1; $includeFilters = (int)($params['include_filters'] ?? 0) === 1;
$cacheParams = $params;
ksort($cacheParams);
$cacheKey = 'conversion_stats_overview_' . md5(json_encode($cacheParams, JSON_UNESCAPED_UNICODE));
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
if ($includeFilters) {
$cached['extend']['filters'] = self::buildFilterOptions();
}
return $cached;
}
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept')); $dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params); [$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1)); $pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -57,7 +44,6 @@ class ConversionLogic
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions();
} }
Cache::set($cacheKey, $result, 60);
return $result; return $result;
} }
@@ -65,9 +51,9 @@ class ConversionLogic
$adminToDeptIds = self::loadAdminDeptMap(); $adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp); self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate); self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate);
self::finalizeAppointmentStats($entities);
self::hydratePaidAuditAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp); self::hydratePaidAuditAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateCompletedOrderStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp); self::hydrateCompletedOrderStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateAccountCostStats($entities, $startDate, $endDate);
if ($dimension === 'dept') { if ($dimension === 'dept') {
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows( [$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
@@ -96,7 +82,6 @@ class ConversionLogic
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions();
} }
Cache::set($cacheKey, $result, 60);
return $result; return $result;
} }
@@ -124,7 +109,6 @@ class ConversionLogic
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions();
} }
Cache::set($cacheKey, $result, 60);
return $result; return $result;
} }
@@ -134,20 +118,11 @@ class ConversionLogic
*/ */
private static function buildFilterOptions(): array private static function buildFilterOptions(): array
{ {
$cacheKey = 'conversion_stats_filters'; return [
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$filters = [
'departments' => DeptLogic::getAllData(), 'departments' => DeptLogic::getAllData(),
'assistants' => DiagnosisLogic::getAssistants(), 'assistants' => DiagnosisLogic::getAssistants(),
'doctors' => DiagnosisLogic::getDoctors(), 'doctors' => DiagnosisLogic::getDoctors(),
]; ];
Cache::set($cacheKey, $filters, 600);
return $filters;
} }
private static function normalizeDimension(string $dimension): string private static function normalizeDimension(string $dimension): string
@@ -316,7 +291,6 @@ class ConversionLogic
'completed_order_amount' => 0.0, 'completed_order_amount' => 0.0,
'completed_order_count' => 0, 'completed_order_count' => 0,
'account_cost' => 0.0, 'account_cost' => 0.0,
'_appointment_groups' => [],
]; ];
} }
@@ -364,6 +338,7 @@ class ConversionLogic
->select() ->select()
->toArray(); ->toArray();
$countsByAdmin = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$adminIds = self::extractFollowAdminIds($row, $wxUserIdToAdminIds); $adminIds = self::extractFollowAdminIds($row, $wxUserIdToAdminIds);
if ($adminIds === []) { if ($adminIds === []) {
@@ -371,9 +346,13 @@ class ConversionLogic
} }
foreach ($adminIds as $adminId) { foreach ($adminIds as $adminId) {
foreach (self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds) as $entityId) { $countsByAdmin[$adminId] = ($countsByAdmin[$adminId] ?? 0) + 1;
$entities[$entityId]['add_fans_count']++; }
} }
foreach ($countsByAdmin as $adminId => $count) {
foreach (self::mapEntityIds($dimension, (int) $adminId, $entityIds, $adminToDeptIds) as $entityId) {
$entities[$entityId]['add_fans_count'] += (int) $count;
} }
} }
} }
@@ -457,10 +436,15 @@ class ConversionLogic
string $startDate, string $startDate,
string $endDate string $endDate
): void { ): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$rows = Db::name('doctor_appointment') $rows = Db::name('doctor_appointment')
->where('appointment_date', '>=', $startDate) ->alias('a')
->where('appointment_date', '<=', $endDate) ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->field('patient_id, doctor_id, assistant_id, status') ->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() ->select()
->toArray(); ->toArray();
@@ -470,61 +454,26 @@ class ConversionLogic
continue; continue;
} }
$sourceAdminId = $dimension === 'doctor' $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
? (int)($row['doctor_id'] ?? 0)
: (int)($row['assistant_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; continue;
} }
$appointmentCount = (int)($row['appointment_count'] ?? 0);
$interviewCount = (int)($row['interview_count'] ?? 0);
foreach ($mappedEntityIds as $entityId) { foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['appointment_total_count']++; $entities[$entityId]['appointment_total_count'] += $appointmentCount;
if (!isset($entities[$entityId]['_appointment_groups'][$diagnosisId])) { $entities[$entityId]['paid_appointment_count'] += 1;
$entities[$entityId]['_appointment_groups'][$diagnosisId] = [ if ($appointmentCount > 1) {
'count' => 0, $entities[$entityId]['free_appointment_count'] += ($appointmentCount - 1);
'has_completed' => false,
];
}
$entities[$entityId]['_appointment_groups'][$diagnosisId]['count']++;
if ((int)($row['status'] ?? 0) === 3) {
$entities[$entityId]['_appointment_groups'][$diagnosisId]['has_completed'] = true;
$entities[$entityId]['interview_count']++;
} }
$entities[$entityId]['interview_count'] += $interviewCount;
} }
} }
} }
/**
* @param array<int, array<string, mixed>> $entities
*/
private static function finalizeAppointmentStats(array &$entities): void
{
foreach ($entities as &$entity) {
$groups = $entity['_appointment_groups'] ?? [];
$paid = 0;
$free = 0;
foreach ($groups as $group) {
$count = (int)($group['count'] ?? 0);
if ($count <= 0) {
continue;
}
$paid += 1;
// 与问诊列表总数口径对齐:
// 同一诊单第一条记为付费挂号,其余挂号记录都记为免费挂号,
// 否则“重复挂号但未完成”的记录会被漏掉,导致付费+免费 < 挂号总数。
if ($count > 1) {
$free += ($count - 1);
}
}
$entity['paid_appointment_count'] = $paid;
$entity['free_appointment_count'] = $free;
unset($entity['_appointment_groups']);
}
unset($entity);
}
/** /**
* @param array<int, array<string, mixed>> $entities * @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds * @param int[] $entityIds
@@ -538,6 +487,7 @@ class ConversionLogic
int $startTimestamp, int $startTimestamp,
int $endTimestamp int $endTimestamp
): void { ): void {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$rows = Db::name('tcm_prescription_order') $rows = Db::name('tcm_prescription_order')
->alias('po') ->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id') ->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
@@ -546,29 +496,47 @@ class ConversionLogic
->where('po.prescription_audit_status', 1) ->where('po.prescription_audit_status', 1)
->where('po.payment_slip_audit_status', 1) ->where('po.payment_slip_audit_status', 1)
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp]) ->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
->field('po.amount, po.internal_cost, rx.assistant_id, rx.creator_id') ->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count")
->group($sourceExpr)
->select() ->select()
->toArray(); ->toArray();
foreach ($rows as $row) { foreach ($rows as $row) {
$sourceAdminId = $dimension === 'doctor' $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
? (int)($row['creator_id'] ?? 0)
: (int)($row['assistant_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; continue;
} }
$internalCost = round((float)($row['internal_cost'] ?? 0), 2); $orderCount = (int)($row['order_count'] ?? 0);
foreach ($mappedEntityIds as $entityId) { foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_count']++; $entities[$entityId]['completed_order_count'] += $orderCount;
$entities[$entityId]['account_cost'] = round($entities[$entityId]['account_cost'] + $internalCost, 2);
} }
} }
} }
/**
* 账户消耗:来源于独立维护表 zyt_account_cost,为全局日维度数据。
* 由于当前没有部门/医助/医生拆分来源,只在汇总层使用,不向各明细行分摊。
*
* @param array<int, array<string, mixed>> $entities
*/
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate): void
{
$totalAmount = round((float) Db::name('account_cost')
->where('cost_date', '>=', $startDate)
->where('cost_date', '<=', $endDate)
->sum('amount'), 2);
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = $totalAmount;
}
unset($entity);
}
/** /**
* 诊单金额:按支付单审核通过(payment_slip_audit_status=1)的业务订单 amount 汇总 * 诊单金额:按支付单审核通过(payment_slip_audit_status=1)的业务订单 amount 汇总
* *
@@ -584,27 +552,27 @@ class ConversionLogic
int $startTimestamp, int $startTimestamp,
int $endTimestamp int $endTimestamp
): void { ): void {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$rows = Db::name('tcm_prescription_order') $rows = Db::name('tcm_prescription_order')
->alias('po') ->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id') ->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
->whereNull('po.delete_time') ->whereNull('po.delete_time')
->where('po.payment_slip_audit_status', 1) ->where('po.payment_slip_audit_status', 1)
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp]) ->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
->field('po.amount, rx.assistant_id, rx.creator_id') ->fieldRaw("{$sourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
->group($sourceExpr)
->select() ->select()
->toArray(); ->toArray();
foreach ($rows as $row) { foreach ($rows as $row) {
$sourceAdminId = $dimension === 'doctor' $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
? (int)($row['creator_id'] ?? 0)
: (int)($row['assistant_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; continue;
} }
$amount = round((float)($row['amount'] ?? 0), 2); $amount = round((float)($row['total_amount'] ?? 0), 2);
foreach ($mappedEntityIds as $entityId) { foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2); $entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2);
} }
@@ -641,6 +609,7 @@ class ConversionLogic
private static function finalizeRows(array $entities): array private static function finalizeRows(array $entities): array
{ {
$rows = []; $rows = [];
$globalAccountCost = 0.0;
foreach ($entities as $entity) { foreach ($entities as $entity) {
$paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0); $paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0);
$freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0); $freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0);
@@ -651,12 +620,14 @@ class ConversionLogic
$completedOrderCount = (int)$entity['completed_order_count']; $completedOrderCount = (int)$entity['completed_order_count'];
$completedOrderAmount = round((float)$entity['completed_order_amount'], 2); $completedOrderAmount = round((float)$entity['completed_order_amount'], 2);
$accountCost = round((float)$entity['account_cost'], 2); $accountCost = round((float)$entity['account_cost'], 2);
$globalAccountCost = max($globalAccountCost, round((float)($entity['_global_account_cost'] ?? 0), 2));
$effectiveAccountCost = $globalAccountCost > 0 ? $globalAccountCost : $accountCost;
$entity['paid_appointment_count'] = $paidAppointmentCount; $entity['paid_appointment_count'] = $paidAppointmentCount;
$entity['free_appointment_count'] = $freeAppointmentCount; $entity['free_appointment_count'] = $freeAppointmentCount;
$entity['appointment_total_count'] = $appointmentTotalCount; $entity['appointment_total_count'] = $appointmentTotalCount;
$entity['completed_order_amount'] = $completedOrderAmount; $entity['completed_order_amount'] = $completedOrderAmount;
$entity['account_cost'] = $accountCost; $entity['account_cost'] = $effectiveAccountCost;
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
@@ -664,8 +635,8 @@ class ConversionLogic
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount); $entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost); $entity['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
$rows[] = $entity; $rows[] = $entity;
} }
@@ -683,6 +654,13 @@ class ConversionLogic
return $right['add_fans_count'] <=> $left['add_fans_count']; return $right['add_fans_count'] <=> $left['add_fans_count'];
}); });
if ($globalAccountCost > 0 && $rows !== []) {
foreach ($rows as &$row) {
$row['_global_account_cost'] = $globalAccountCost;
}
unset($row);
}
return $rows; return $rows;
} }
@@ -815,12 +793,14 @@ class ConversionLogic
$completedOrderCount = (int)$node['completed_order_count']; $completedOrderCount = (int)$node['completed_order_count'];
$completedOrderAmount = round((float)$node['completed_order_amount'], 2); $completedOrderAmount = round((float)$node['completed_order_amount'], 2);
$accountCost = round((float)$node['account_cost'], 2); $accountCost = round((float)$node['account_cost'], 2);
$globalAccountCost = round((float)($node['_global_account_cost'] ?? 0), 2);
$effectiveAccountCost = $globalAccountCost > 0 ? $globalAccountCost : $accountCost;
$node['paid_appointment_count'] = $paidAppointmentCount; $node['paid_appointment_count'] = $paidAppointmentCount;
$node['free_appointment_count'] = $freeAppointmentCount; $node['free_appointment_count'] = $freeAppointmentCount;
$node['appointment_total_count'] = $appointmentTotalCount; $node['appointment_total_count'] = $appointmentTotalCount;
$node['completed_order_amount'] = $completedOrderAmount; $node['completed_order_amount'] = $completedOrderAmount;
$node['account_cost'] = $accountCost; $node['account_cost'] = $effectiveAccountCost;
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
@@ -828,8 +808,11 @@ class ConversionLogic
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$node['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount); $node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$node['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost); $node['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
if ($globalAccountCost > 0) {
$node['_global_account_cost'] = $globalAccountCost;
}
unset($node['sort'], $node['pid']); unset($node['sort'], $node['pid']);
return $node; return $node;
@@ -853,6 +836,7 @@ class ConversionLogic
'completed_order_count' => 0, 'completed_order_count' => 0,
'account_cost' => 0.0, 'account_cost' => 0.0,
]; ];
$globalAccountCost = 0.0;
foreach ($rows as $row) { foreach ($rows as $row) {
$summary['add_fans_count'] += (int)$row['add_fans_count']; $summary['add_fans_count'] += (int)$row['add_fans_count'];
@@ -864,9 +848,11 @@ class ConversionLogic
$summary['interview_count'] += (int)$row['interview_count']; $summary['interview_count'] += (int)$row['interview_count'];
$summary['completed_order_count'] += (int)$row['completed_order_count']; $summary['completed_order_count'] += (int)$row['completed_order_count'];
$summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2); $summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2);
$summary['account_cost'] = round($summary['account_cost'] + (float)$row['account_cost'], 2); $globalAccountCost = max($globalAccountCost, round((float)($row['_global_account_cost'] ?? 0), 2));
} }
$summary['account_cost'] = $globalAccountCost;
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']); $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']); $summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']); $summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\finance;
use app\common\model\finance\AccountCost;
use app\common\validate\BaseValidate;
class AccountCostValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|checkExists',
'cost_date' => 'require|dateFormat:Y-m-d',
'amount' => 'require|float|egt:0',
'remark' => 'max:255',
];
protected $message = [
'id.require' => '参数缺失',
'cost_date.require' => '请选择日期',
'cost_date.dateFormat' => '日期格式错误',
'amount.require' => '请输入账户消耗金额',
'amount.float' => '账户消耗金额格式错误',
'amount.egt' => '账户消耗金额不能小于0',
'remark.max' => '备注不能超过255个字符',
];
public function sceneAdd()
{
return $this->remove('id', true);
}
public function sceneEdit()
{
return $this->remove('cost_date', true);
}
public function sceneDetail()
{
return $this->only(['id']);
}
public function checkExists($value)
{
$model = AccountCost::find($value);
if (!$model) {
return '记录不存在';
}
return true;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\common\model\finance;
use app\common\model\BaseModel;
class AccountCost extends BaseModel
{
protected $name = 'account_cost';
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $dateFormat = 'Y-m-d H:i:s';
}
@@ -4,13 +4,13 @@
-- 查询权限: stats.conversion/overview -- 查询权限: stats.conversion/overview
INSERT INTO `zyt_system_menu` INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES VALUES
(0, 'M', '数据统计', 'el-icon-DataAnalysis', 160, '', '/stats', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); (0, 'M', '数据统计', 'el-icon-DataAnalysis', 160, '', '/stats', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
SET @stats_root_id = LAST_INSERT_ID(); SET @stats_root_id = LAST_INSERT_ID();
INSERT INTO `zyt_system_menu` INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES VALUES
(@stats_root_id, 'C', '综合转化统计', '', 1, 'stats.conversion/overview', '/stats/conversion', '/stats/conversion/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); (@stats_root_id, 'C', '综合转化统计', '', 1, 'stats.conversion/overview', '/stats/conversion', '/stats/conversion/index', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
@@ -0,0 +1,15 @@
SET @finance_pid = 166;
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES
(@finance_pid, 'C', '账户消耗列表', '', 20, 'finance.account_cost/lists', 'account_cost', 'finance/account_cost/index', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
SET @account_cost_menu_id = LAST_INSERT_ID();
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
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());
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `zyt_account_cost` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`cost_date` date NOT NULL COMMENT '账户消耗日期',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '账户消耗金额',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`creator_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建人ID',
`creator_name` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人姓名',
`updater_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后修改人ID',
`updater_name` varchar(64) NOT NULL DEFAULT '' 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_cost_date` (`cost_date`),
KEY `idx_update_time` (`update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户消耗表';