This commit is contained in:
Your Name
2026-04-27 15:30:48 +08:00
10 changed files with 457 additions and 43 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\finance;
use app\common\logic\BaseLogic;
use app\common\model\dept\Dept;
use app\common\model\finance\AccountCost;
use app\common\service\qywx\MediaChannelService;
@@ -16,14 +17,31 @@ class AccountCostLogic extends BaseLogic
$costDate = (string) $params['cost_date'];
$mediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
$mediaChannelName = MediaChannelService::getNameByCode($mediaChannelCode);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$deptId = $supportsDeptBinding ? (int) ($params['dept_id'] ?? 0) : 0;
$deptName = $supportsDeptBinding ? self::resolveDeptName($deptId) : '';
if (AccountCost::where('cost_date', $costDate)->where('media_channel_code', $mediaChannelCode)->count() > 0) {
self::setError('该日期下所选渠道的账户消耗已存在,请直接编辑');
if ($supportsDeptBinding && $deptName === '') {
self::setError('请选择绑定部门');
return false;
}
AccountCost::create([
$existsQuery = AccountCost::where('cost_date', $costDate)
->where('media_channel_code', $mediaChannelCode);
if ($supportsDeptBinding) {
$existsQuery->where('dept_id', $deptId);
}
if ($existsQuery->count() > 0) {
self::setError($supportsDeptBinding
? '该日期下所选渠道和部门的账户消耗已存在,请直接编辑'
: '该日期下所选渠道的账户消耗已存在,请直接编辑');
return false;
}
$payload = [
'cost_date' => $costDate,
'media_channel_code' => $mediaChannelCode,
'media_channel_name' => $mediaChannelName,
@@ -33,7 +51,13 @@ class AccountCostLogic extends BaseLogic
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
]);
];
if ($supportsDeptBinding) {
$payload['dept_id'] = $deptId;
$payload['dept_name'] = $deptName;
}
AccountCost::create($payload);
return true;
} catch (\Throwable $e) {
@@ -53,6 +77,31 @@ class AccountCostLogic extends BaseLogic
return false;
}
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$deptId = $supportsDeptBinding ? (int) ($params['dept_id'] ?? 0) : 0;
$deptName = $supportsDeptBinding ? self::resolveDeptName($deptId) : '';
if ($supportsDeptBinding && $deptName === '') {
self::setError('请选择绑定部门');
return false;
}
if ($supportsDeptBinding) {
if (AccountCost::where('id', '<>', (int) $params['id'])
->where('cost_date', (string) $model->cost_date)
->where('media_channel_code', (string) $model->media_channel_code)
->where('dept_id', $deptId)
->count() > 0) {
self::setError('该日期下所选渠道和部门的账户消耗已存在,请直接编辑');
return false;
}
}
if ($supportsDeptBinding) {
$model->dept_id = $deptId;
$model->dept_name = $deptName;
}
$model->amount = round((float) $params['amount'], 2);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
@@ -91,4 +140,18 @@ class AccountCostLogic extends BaseLogic
return false;
}
}
private static function resolveDeptName(int $deptId): string
{
if ($deptId <= 0) {
return '';
}
$dept = Dept::find($deptId);
if (!$dept) {
return '';
}
return (string) ($dept->name ?? '');
}
}
@@ -6,6 +6,7 @@ namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\finance\AccountCost;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
@@ -28,10 +29,9 @@ class ConversionLogic
$pageNo = max(1, (int)($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
$allocationParams = self::buildAllocationScopeParams($dimension, $params);
$entities = self::loadEntities($dimension, $params);
$entityIds = array_keys($entities);
$allocationEntities = self::loadEntities($dimension, $allocationParams);
$allocationEntities = self::loadEntities($dimension, $params);
$allocationEntityIds = array_keys($allocationEntities);
if ($entityIds === []) {
@@ -60,11 +60,54 @@ class ConversionLogic
$adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
$allocationAddFansCount = self::sumEntityAddFans(array_values($allocationEntities));
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel);
$globalAccountCost = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
$eligibleDeptIds = $restrictAccountCostByDept ? self::expandDeptIdsWithDescendants($accountCostDeptIds) : $accountCostDeptIds;
if ($restrictStatsByDept) {
$entities = self::filterEntitiesByChannelDeptScope($entities, $dimension, $eligibleDeptIds, $adminToDeptIds);
$allocationEntities = self::filterEntitiesByChannelDeptScope($allocationEntities, $dimension, $eligibleDeptIds, $adminToDeptIds);
}
$entityIds = array_keys($entities);
$allocationEntityIds = array_keys($allocationEntities);
if ($entityIds === []) {
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
}
return $result;
}
$allocationAddFansCount = self::sumEntityAddFansForAccountCost(
array_values($allocationEntities),
$dimension,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds
);
if ($dimension === 'dept') {
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
@@ -73,7 +116,10 @@ class ConversionLogic
$pageNo,
$pageSize,
$filterEmptyEntities,
$allocationAddFansCount
$allocationAddFansCount,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds
);
$result = [
@@ -99,7 +145,15 @@ class ConversionLogic
return $result;
}
$rows = self::finalizeRows($entities, $filterEmptyEntities, $allocationAddFansCount);
$rows = self::finalizeRows(
$entities,
$filterEmptyEntities,
$allocationAddFansCount,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds,
$dimension
);
$count = count($rows);
$offset = ($pageNo - 1) * $pageSize;
@@ -126,15 +180,6 @@ class ConversionLogic
return $result;
}
private static function buildAllocationScopeParams(string $dimension, array $params): array
{
$allocationParams = $params;
unset($allocationParams['dept_id'], $allocationParams['assistant_id'], $allocationParams['doctor_id']);
return $allocationParams;
}
/**
* @return array<string, mixed>
*/
@@ -373,6 +418,67 @@ class ConversionLogic
return $map;
}
/**
* @param int[] $deptIds
* @return int[]
*/
private static function expandDeptIdsWithDescendants(array $deptIds): array
{
if ($deptIds === []) {
return [];
}
$expanded = [];
foreach (array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $deptId): bool => $deptId > 0))) as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $expandedDeptId) {
if ($expandedDeptId > 0) {
$expanded[$expandedDeptId] = $expandedDeptId;
}
}
}
return array_values($expanded);
}
/**
* 自媒体渠道选中时,统计范围只允许落在渠道绑定部门及其子部门内。
*
* @param array<int, array<string, mixed>> $entities
* @param array<int, int[]> $adminToDeptIds
* @return array<int, array<string, mixed>>
*/
private static function filterEntitiesByChannelDeptScope(array $entities, string $dimension, array $eligibleDeptIds, array $adminToDeptIds): array
{
if ($eligibleDeptIds === []) {
return [];
}
$eligibleDeptMap = array_fill_keys(array_map('intval', $eligibleDeptIds), true);
$filtered = [];
foreach ($entities as $key => $entity) {
$entityId = (int)($entity['id'] ?? 0);
if ($entityId <= 0) {
continue;
}
if ($dimension === 'dept') {
if (isset($eligibleDeptMap[$entityId])) {
$filtered[$key] = $entity;
}
continue;
}
foreach (($adminToDeptIds[$entityId] ?? []) as $deptId) {
if (isset($eligibleDeptMap[(int)$deptId])) {
$filtered[$key] = $entity;
break;
}
}
}
return $filtered;
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
@@ -639,22 +745,43 @@ class ConversionLogic
}
/**
* 账户消耗:来源于独立维护表 zyt_account_cost,为全局日维度数据
* 由于当前没有部门/医助/医生拆分来源,只在当前筛选口径的汇总层使用,不向各明细行分摊。
* 账户消耗:来源于独立维护表 zyt_account_cost。
*
* @param array<int, array<string, mixed>> $entities
* @return array{0: float, 1: int[]}
*/
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate, string $mediaChannelCode): float
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate, string $mediaChannelCode): array
{
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost')
->where('cost_date', '>=', $startDate)
->where('cost_date', '<=', $endDate);
if ($supportsDeptBinding) {
$query->field('dept_id, amount');
} else {
$query->field('amount');
}
if ($mediaChannelCode !== '') {
$query->where('media_channel_code', $mediaChannelCode);
}
$totalAmount = round((float) $query->sum('amount'), 2);
if ($supportsDeptBinding) {
$query->where('dept_id', '>', 0);
}
$rows = $query->select()->toArray();
$totalAmount = 0.0;
$deptIds = [];
foreach ($rows as $row) {
$amount = round((float)($row['amount'] ?? 0), 2);
$totalAmount = round($totalAmount + $amount, 2);
$deptId = $supportsDeptBinding ? (int)($row['dept_id'] ?? 0) : 0;
if ($supportsDeptBinding && $deptId > 0) {
$deptIds[$deptId] = $deptId;
}
}
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
@@ -662,7 +789,7 @@ class ConversionLogic
}
unset($entity);
return $totalAmount;
return [$totalAmount, array_values($deptIds)];
}
@@ -694,7 +821,15 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $entities
* @return array<int, array<string, mixed>>
*/
private static function finalizeRows(array $entities, bool $excludeEmpty = false, ?int $allocationAddFansCount = null): array
private static function finalizeRows(
array $entities,
bool $excludeEmpty = false,
?int $allocationAddFansCount = null,
bool $restrictAccountCostByDept = false,
array $eligibleDeptIds = [],
array $adminToDeptIds = [],
string $dimension = 'dept'
): array
{
$rows = [];
$filteredEntities = $excludeEmpty
@@ -714,7 +849,8 @@ class ConversionLogic
$completedOrderCount = (int)$entity['completed_order_count'];
$businessOrderAmount = round((float)$entity['business_order_amount'], 2);
$completedOrderAmount = round((float)$entity['completed_order_amount'], 2);
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount);
$isEligibleForAccountCost = self::isAccountCostEligible($dimension, $entity, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds);
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
$entity['paid_appointment_count'] = $paidAppointmentCount;
$entity['free_appointment_count'] = $freeAppointmentCount;
@@ -758,7 +894,17 @@ 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, bool $excludeEmpty = false, ?int $allocationAddFansCount = null): array
private static function buildDeptTreeRows(
array $entities,
int $selectedDeptId,
int $pageNo,
int $pageSize,
bool $excludeEmpty = false,
?int $allocationAddFansCount = null,
bool $restrictAccountCostByDept = false,
array $eligibleDeptIds = [],
array $adminToDeptIds = []
): array
{
$childrenByPid = [];
foreach ($entities as $entity) {
@@ -845,11 +991,11 @@ class ConversionLogic
$globalAccountCost = self::extractGlobalAccountCost($allRowsRaw);
$globalAddFansCount = $allocationAddFansCount ?? self::sumNodeAddFans($allRowsRaw);
$allRows = array_map(
static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount),
static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds),
$allRowsRaw
);
$chartRows = array_map(
static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount),
static fn (array $row): array => self::finalizeDeptNode($row, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds),
$chartRowsRaw
);
@@ -910,10 +1056,17 @@ class ConversionLogic
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private static function finalizeDeptNode(array $node, float $globalAccountCost, int $globalAddFansCount): array
private static function finalizeDeptNode(
array $node,
float $globalAccountCost,
int $globalAddFansCount,
bool $restrictAccountCostByDept = false,
array $eligibleDeptIds = [],
array $adminToDeptIds = []
): array
{
$node['children'] = array_map(
static fn (array $child): array => self::finalizeDeptNode($child, $globalAccountCost, $globalAddFansCount),
static fn (array $child): array => self::finalizeDeptNode($child, $globalAccountCost, $globalAddFansCount, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds),
$node['children'] ?? []
);
$paidAppointmentCount = (int)($node['paid_appointment_count'] ?? 0);
@@ -925,14 +1078,19 @@ class ConversionLogic
$completedOrderCount = (int)$node['completed_order_count'];
$businessOrderAmount = round((float)$node['business_order_amount'], 2);
$completedOrderAmount = round((float)$node['completed_order_amount'], 2);
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount);
$isEligibleForAccountCost = self::isDeptAccountCostEligible((int) ($node['id'] ?? 0), $restrictAccountCostByDept, $eligibleDeptIds);
$effectiveAccountCost = self::allocateAccountCost($globalAccountCost, $globalAddFansCount, $addFansCount, $isEligibleForAccountCost);
$childrenAccountCost = 0.0;
foreach ($node['children'] as $child) {
$childrenAccountCost = round($childrenAccountCost + (float)($child['account_cost'] ?? 0), 2);
}
$node['paid_appointment_count'] = $paidAppointmentCount;
$node['free_appointment_count'] = $freeAppointmentCount;
$node['appointment_total_count'] = $appointmentTotalCount;
$node['business_order_amount'] = $businessOrderAmount;
$node['completed_order_amount'] = $completedOrderAmount;
$node['account_cost'] = $effectiveAccountCost;
$node['account_cost'] = $isEligibleForAccountCost ? $effectiveAccountCost : $childrenAccountCost;
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
@@ -1035,9 +1193,61 @@ class ConversionLogic
return $total;
}
private static function allocateAccountCost(float $globalAccountCost, int $globalAddFansCount, int $rowAddFansCount): float
private static function isAccountCostEligible(string $dimension, array $entity, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds): bool
{
if ($globalAccountCost <= 0 || $globalAddFansCount <= 0 || $rowAddFansCount <= 0) {
if (!$restrictAccountCostByDept) {
return true;
}
if ($dimension === 'dept') {
return self::isDeptAccountCostEligible((int) ($entity['id'] ?? 0), true, $eligibleDeptIds);
}
$adminId = (int) ($entity['id'] ?? 0);
$deptIds = $adminToDeptIds[$adminId] ?? [];
foreach ($deptIds as $deptId) {
if (in_array((int) $deptId, $eligibleDeptIds, true)) {
return true;
}
}
return false;
}
private static function isDeptAccountCostEligible(int $deptId, bool $restrictAccountCostByDept, array $eligibleDeptIds): bool
{
if (!$restrictAccountCostByDept) {
return true;
}
if ($deptId <= 0) {
return false;
}
return in_array($deptId, $eligibleDeptIds, true);
}
private static function sumEntityAddFansForAccountCost(array $entities, string $dimension, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds): int
{
if (!$restrictAccountCostByDept) {
return self::sumEntityAddFans($entities);
}
$total = 0;
foreach ($entities as $entity) {
if (!self::isAccountCostEligible($dimension, $entity, true, $eligibleDeptIds, $adminToDeptIds)) {
continue;
}
$total += (int) ($entity['add_fans_count'] ?? 0);
}
return $total;
}
private static function allocateAccountCost(float $globalAccountCost, int $globalAddFansCount, int $rowAddFansCount, bool $isEligible = true): float
{
if (!$isEligible || $globalAccountCost <= 0 || $globalAddFansCount <= 0 || $rowAddFansCount <= 0) {
return 0.0;
}