This commit is contained in:
Your Name
2026-08-05 11:08:02 +08:00
parent 57a7c415a1
commit c2b7018a22
429 changed files with 13316 additions and 350 deletions
@@ -0,0 +1,707 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemRole;
use app\common\model\dept\Dept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 数据驾驶舱聚合逻辑。
*
* 数据口径:
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
* - 今日加粉、挂号、面诊和转化率继续沿用 ConversionLogic;它们不是业绩指标。
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
*/
class PerformanceDashboardLogic
{
private const TREND_DAYS = 7;
/**
* @return array<string, mixed>
*/
public static function overview(int $adminId, array $adminInfo): array
{
$today = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));
$dayBeforeYesterday = date('Y-m-d', strtotime('-2 days'));
$monthStart = date('Y-m-01');
$previousMonthStart = date('Y-m-01', strtotime('first day of previous month'));
$previousMonthLastDay = (int) date('t', strtotime($previousMonthStart));
$comparisonDay = min((int) date('j'), $previousMonthLastDay);
$previousMonthComparableEnd = date(
'Y-m-d',
strtotime($previousMonthStart . ' +' . max(0, $comparisonDay - 1) . ' days')
);
$trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
$scope = self::buildScopeContext($adminId, $adminInfo);
/** @var array<int>|null $visibleAdminIds */
$visibleAdminIds = $scope['_visible_admin_ids'];
unset($scope['_visible_admin_ids']);
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
$previousMonthAmount = self::sumDailyMetric(
$orderDaily,
$previousMonthStart,
$previousMonthComparableEnd,
'amount'
);
$yesterdayAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
$dayBeforeAmount = self::dailyMetric($orderDaily, $dayBeforeYesterday, 'amount');
$personalMonthAmount = self::sumDailyMetric($personalOrderDaily, $monthStart, $today, 'amount');
$todayOverview = ConversionLogic::overview([
'dimension' => 'dept',
'time_type' => 'today',
'include_members' => 0,
'include_filters' => 0,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
$todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : [];
// 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。
$todayPerformanceOverview = YejiStatsLogic::overview([
'start_date' => $today,
'end_date' => $today,
], $adminId, $adminInfo);
$appointmentRanking = self::buildAppointmentRanking($adminId, $adminInfo, $scope);
$performanceRanking = self::buildPerformanceRanking(
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
);
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
'start_date' => $trendStart,
'end_date' => $today,
], $adminId, $adminInfo);
$trend = self::buildTrend($trendStart, $today, $visibleAdminIds, $orderDaily, $trendContext);
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
$target = self::buildTargetProgress(
$adminId,
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF),
date('Y-m'),
$monthAmount,
$personalMonthAmount
);
return [
'scope' => $scope,
'performance' => [
'month_amount' => round($monthAmount, 2),
'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount),
'month_compare_label' => '较上月同期',
'yesterday_amount' => round($yesterdayAmount, 2),
'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount),
'yesterday_compare_label' => '较前一日',
'personal_month_amount' => round($personalMonthAmount, 2),
],
'today' => [
'add_fans_count' => (int) ($trend['leads'][$todayTrendIndex] ?? 0),
'appointment_total_count' => (int) ($trend['appointments'][$todayTrendIndex] ?? 0),
'interview_count' => (int) ($todaySummary['interview_count'] ?? 0),
// 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。
'completed_order_count' => (int) self::dailyMetric($orderDaily, $today, 'count'),
'completed_order_amount' => self::dailyMetric($orderDaily, $today, 'amount'),
'paid_appointment_rate' => round((float) ($todaySummary['paid_appointment_rate'] ?? 0), 2),
'interview_receive_rate' => round((float) ($todaySummary['interview_receive_rate'] ?? 0), 2),
],
'rankings' => [
'appointments' => $appointmentRanking,
'performance' => [
'title' => '今日部门业绩排行',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $performanceRanking,
],
],
'trend' => $trend,
'target' => $target,
'meta' => [
'generated_at' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
'rate_note' => '近 7 天趋势与业绩统计一致:挂号排除已取消记录,进线仅统计当前范围内可归属业绩中心的新增客户事件,诊单按订单创建时间统计并排除履约 4/9/10。',
],
];
}
/**
* @return array<string, mixed>
*/
private static function buildScopeContext(int $adminId, array $adminInfo): array
{
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIds), static fn (int $id): bool => $id > 0)));
$roleNames = [];
if ($roleIds !== []) {
$roleNames = SystemRole::whereIn('id', $roleIds)
->whereNull('delete_time')
->order('sort', 'desc')
->column('name');
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
}
$deptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
$deptIds = array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $id): bool => $id > 0)));
$deptNames = [];
if ($deptIds !== []) {
$deptNames = Dept::whereIn('id', $deptIds)
->whereNull('delete_time')
->order('id', 'asc')
->column('name');
$deptNames = array_values(array_filter(array_map('strval', $deptNames)));
}
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
$scopeKey = $isRoot ? 'root' : [
DataScopeService::SCOPE_ALL => 'all',
DataScopeService::SCOPE_DEPT_AND_CHILD => 'dept_children',
DataScopeService::SCOPE_DEPT => 'dept',
DataScopeService::SCOPE_SELF => 'self',
][$scopeValue] ?? 'self';
$scopeLabel = $isRoot ? '全部数据' : DataScopeService::scopeLabel($scopeValue);
if ($visibleAdminIds === null) {
$visibleMemberCount = (int) Db::name('admin')->whereNull('delete_time')->count();
} else {
$visibleMemberCount = count($visibleAdminIds);
}
return [
'key' => $scopeKey,
'scope_value' => $scopeValue,
'label' => $scopeLabel,
'is_limited' => $visibleAdminIds !== null,
'viewer_name' => (string) ($adminInfo['name'] ?? $adminInfo['account'] ?? ''),
'role_ids' => $roleIds,
'role_names' => $roleNames,
'department_names' => $deptNames,
'visible_member_count' => $visibleMemberCount,
'_visible_admin_ids' => $visibleAdminIds,
];
}
/**
* @param array<int>|null $visibleAdminIds
* @return array<string, array{amount: float, count: int}>
*/
private static function loadPerformanceOrderDaily(string $startDate, string $endDate, ?array $visibleAdminIds): array
{
if ($visibleAdminIds === []) {
return [];
}
$startTs = (int) strtotime($startDate . ' 00:00:00');
$endTs = (int) strtotime($endDate . ' 23:59:59');
$query = Db::name('tcm_prescription_order')
->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
if ($visibleAdminIds !== null) {
$query->whereIn('po.creator_id', $visibleAdminIds);
}
$rows = $query
->fieldRaw("FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, SUM(po.amount) AS amount_sum, COUNT(*) AS order_count")
->group('date_label')
->order('date_label', 'asc')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date === '') {
continue;
}
$out[$date] = [
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
'count' => (int) ($row['order_count'] ?? 0),
];
}
return $out;
}
/**
* @param array<string, array{amount: float, count: int}> $daily
*/
private static function sumDailyMetric(array $daily, string $startDate, string $endDate, string $metric): float
{
$sum = 0.0;
foreach ($daily as $date => $values) {
if ($date < $startDate || $date > $endDate) {
continue;
}
$sum += (float) ($values[$metric] ?? 0);
}
return round($sum, 2);
}
/**
* @param array<string, array{amount: float, count: int}> $daily
*/
private static function dailyMetric(array $daily, string $date, string $metric): float
{
return round((float) ($daily[$date][$metric] ?? 0), 2);
}
private static function relativeChange(float $current, float $previous): ?float
{
if (abs($previous) < 0.00001) {
return null;
}
return round((($current - $previous) / $previous) * 100, 2);
}
/**
* @param array<string, mixed> $scope
* @return array<string, mixed>
*/
private static function buildAppointmentRanking(int $adminId, array $adminInfo, array $scope): array
{
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
&& in_array(1, $roleIds, true)
&& !in_array(2, $roleIds, true);
if ($isDoctorSelf) {
$doctorStats = DoctorDailyStatsLogic::overview([
'start_date' => date('Y-m-d'),
'end_date' => date('Y-m-d'),
], $adminId, $adminInfo);
$items = [];
foreach (array_slice($doctorStats['rows'] ?? [], 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['doctor_name'] ?? ''),
'count' => (int) ($row['appointment_total'] ?? 0),
'amount' => round((float) ($row['deal_amount'] ?? 0), 2),
];
}
return [
'title' => '实时挂号排行',
'kind' => 'doctor',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $items,
];
}
$rankingVisibleAdminIds = null;
$rankingScopeLabel = (string) ($scope['label'] ?? '');
if (
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
&& in_array(2, $roleIds, true)
) {
$departmentAssistantIds = self::directDepartmentAssistantIds($adminId);
if ($departmentAssistantIds !== []) {
$rankingVisibleAdminIds = $departmentAssistantIds;
$rankingScopeLabel = '本人所属部门';
}
}
$assistantStats = ConversionLogic::overview([
'dimension' => 'assistant',
'time_type' => 'today',
'include_filters' => 0,
'page_no' => 1,
'page_size' => $rankingVisibleAdminIds !== null ? max(1, count($rankingVisibleAdminIds)) : 100,
], $adminId, $adminInfo, $rankingVisibleAdminIds);
$rows = is_array($assistantStats['lists'] ?? null) ? $assistantStats['lists'] : [];
usort($rows, static function (array $a, array $b): int {
$byAppointment = (int) ($b['appointment_total_count'] ?? 0) <=> (int) ($a['appointment_total_count'] ?? 0);
if ($byAppointment !== 0) {
return $byAppointment;
}
$byAmount = (float) ($b['completed_order_amount'] ?? 0) <=> (float) ($a['completed_order_amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
});
$items = [];
foreach (array_slice($rows, 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['appointment_total_count'] ?? 0),
'amount' => round((float) ($row['completed_order_amount'] ?? 0), 2),
];
}
return [
'title' => '实时挂号排行',
'kind' => 'assistant',
'scope_label' => $rankingScopeLabel,
'items' => $items,
];
}
/**
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
*
* @return int[]
*/
private static function directDepartmentAssistantIds(int $adminId): array
{
if ($adminId <= 0) {
return [];
}
$activeAdmin = Db::name('admin')
->where('id', $adminId)
->whereNull('delete_time')
->value('id');
if ((int) $activeAdmin <= 0) {
return [];
}
$deptIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->where('ad.admin_id', $adminId)
->column('ad.dept_id');
$deptIds = array_values(array_unique(array_filter(
array_map('intval', $deptIds),
static fn (int $id): bool => $id > 0
)));
if ($deptIds === []) {
return [];
}
$assistantIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->join('admin a', 'a.id = ad.admin_id AND a.delete_time IS NULL', 'INNER')
->join('admin_role ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
->join('system_role sr', 'sr.id = ar.role_id AND sr.delete_time IS NULL', 'INNER')
->whereIn('ad.dept_id', $deptIds)
->distinct(true)
->column('a.id');
return array_values(array_unique(array_filter(
array_map('intval', $assistantIds),
static fn (int $id): bool => $id > 0
)));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private static function buildPerformanceRanking(array $rows): array
{
usort($rows, static function (array $a, array $b): int {
$byAmount = (float) ($b['performance_amount'] ?? 0) <=> (float) ($a['performance_amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
$byCount = (int) ($b['deal_order_count'] ?? 0) <=> (int) ($a['deal_order_count'] ?? 0);
if ($byCount !== 0) {
return $byCount;
}
return (int) ($a['dept_id'] ?? 0) <=> (int) ($b['dept_id'] ?? 0);
});
$items = [];
foreach (array_slice($rows, 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['dept_id'] ?? 0),
'name' => (string) ($row['dept_name'] ?? '未归属中心'),
'amount' => round((float) ($row['performance_amount'] ?? 0), 2),
'count' => (int) ($row['deal_order_count'] ?? 0),
];
}
return $items;
}
/**
* @param array<int>|null $visibleAdminIds
* @param array<string, array{amount: float, count: int}> $orderDaily
* @param array<string, mixed> $trendContext
* @return array<string, mixed>
*/
private static function buildTrend(
string $startDate,
string $endDate,
?array $visibleAdminIds,
array $orderDaily,
array $trendContext
): array {
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
? $trendContext['adminToPrimary']
: [];
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
? array_values(array_map('intval', $trendContext['tableRowDeptIds']))
: [];
$leadDaily = self::loadLeadDaily($startDate, $endDate, $adminToPrimary, $tableRowDeptIds);
$appointmentDaily = self::loadAppointmentDaily(
$startDate,
$endDate,
$visibleAdminIds,
$adminToPrimary,
$tableRowDeptIds
);
$dates = [];
$appointments = [];
$leads = [];
$orders = [];
$cursor = strtotime($startDate);
$end = strtotime($endDate);
while ($cursor <= $end) {
$date = date('Y-m-d', $cursor);
$dates[] = date('m-d', $cursor);
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
$leads[] = (int) ($leadDaily[$date] ?? 0);
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
$cursor = strtotime('+1 day', $cursor);
}
return [
'date_range' => [$startDate, $endDate],
'dates' => $dates,
'appointments' => $appointments,
'leads' => $leads,
'orders' => $orders,
];
}
/**
* 与 YejiStatsLogic 的“进线”一致:只有能映射到当前业绩中心展示行的管理员事件才计入。
*
* @param array<int, int> $adminToPrimary
* @param int[] $tableRowDeptIds
* @return array<string, int>
*/
private static function loadLeadDaily(
string $startDate,
string $endDate,
array $adminToPrimary,
array $tableRowDeptIds
): array
{
$rowFlip = array_flip($tableRowDeptIds);
$mappedAdminIds = [];
foreach ($adminToPrimary as $adminId => $deptId) {
$adminId = (int) $adminId;
$deptId = (int) $deptId;
if ($adminId > 0 && isset($rowFlip[$deptId])) {
$mappedAdminIds[] = $adminId;
}
}
$mappedAdminIds = array_values(array_unique($mappedAdminIds));
if ($mappedAdminIds === []) {
return [];
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL', 'INNER')
->where('e.change_type', 'add_external_contact')
->whereIn('a.id', $mappedAdminIds)
->where('e.event_time', 'between', [
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
]);
$rows = $query
->fieldRaw("FROM_UNIXTIME(e.event_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count")
->group('date_label')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date !== '') {
$out[$date] = (int) ($row['item_count'] ?? 0);
}
}
return $out;
}
/**
* @param array<int>|null $visibleAdminIds
* 与 YejiStatsLogic 的“预约诊单”一致:appointment_date,状态 1/3/4
* 归属优先挂号医助、再诊单医助,缺失时回退医生;受限账号只保留当前业绩中心展示行。
*
* @param array<int>|null $visibleAdminIds
* @param array<int, int> $adminToPrimary
* @param int[] $tableRowDeptIds
* @return array<string, int>
*/
private static function loadAppointmentDaily(
string $startDate,
string $endDate,
?array $visibleAdminIds,
array $adminToPrimary,
array $tableRowDeptIds
): array {
if ($visibleAdminIds === []) {
return [];
}
$effectiveAssistantSql = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', 'between', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
$rows = $query
->field([
'a.appointment_date AS date_label',
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
'a.doctor_id',
Db::raw('COUNT(*) AS appointment_count'),
])
->group(['a.appointment_date', $effectiveAssistantSql, 'a.doctor_id'])
->select()
->toArray();
$visibleFlip = $visibleAdminIds !== null ? array_flip($visibleAdminIds) : null;
$rowFlip = array_flip($tableRowDeptIds);
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date === '') {
continue;
}
$effectiveAssistantId = (int) ($row['effective_assistant_id'] ?? 0);
$doctorId = (int) ($row['doctor_id'] ?? 0);
if ($visibleFlip !== null) {
if ($effectiveAssistantId > 0) {
if (!isset($visibleFlip[$effectiveAssistantId])) {
continue;
}
} elseif ($doctorId <= 0 || !isset($visibleFlip[$doctorId])) {
continue;
}
}
$deptId = $effectiveAssistantId > 0
? (int) ($adminToPrimary[$effectiveAssistantId] ?? 0)
: 0;
if ($deptId <= 0 && $doctorId > 0) {
$deptId = (int) ($adminToPrimary[$doctorId] ?? 0);
}
if ($visibleFlip !== null && !isset($rowFlip[$deptId])) {
continue;
}
$out[$date] = ($out[$date] ?? 0) + (int) ($row['appointment_count'] ?? 0);
}
return $out;
}
/**
* @return array<string, mixed>
*/
private static function buildTargetProgress(
int $adminId,
int $scopeValue,
string $yearMonth,
float $completedAmount,
float $personalAmount
): array {
$deptIds = self::targetDeptIds($adminId, $scopeValue);
$query = Db::name('dept_performance_target')->where('year_month', $yearMonth);
if ($deptIds !== null) {
if ($deptIds === []) {
return self::emptyTarget($yearMonth, $completedAmount, $personalAmount);
}
$query->whereIn('dept_id', $deptIds);
}
$rows = $query->field('dept_id, dept_name, target_amount')->select()->toArray();
$targetAmount = 0.0;
foreach ($rows as $row) {
$targetAmount += (float) ($row['target_amount'] ?? 0);
}
$targetAmount = round($targetAmount, 2);
return [
'year_month' => $yearMonth,
'target_amount' => $targetAmount,
'completed_amount' => round($completedAmount, 2),
'completion_rate' => $targetAmount > 0 ? round($completedAmount / $targetAmount * 100, 2) : null,
'personal_amount' => round($personalAmount, 2),
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
'department_count' => count($rows),
];
}
/**
* @return array<int>|null null 表示全部部门。
*/
private static function targetDeptIds(int $adminId, int $scopeValue): ?array
{
if ($scopeValue === DataScopeService::SCOPE_ALL) {
return null;
}
$ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
$ownDeptIds = array_values(array_unique(array_filter(array_map('intval', $ownDeptIds), static fn (int $id): bool => $id > 0)));
if ($ownDeptIds === [] || $scopeValue !== DataScopeService::SCOPE_DEPT_AND_CHILD) {
return $ownDeptIds;
}
$out = [];
foreach ($ownDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$out[$id] = true;
}
}
}
return array_keys($out);
}
/**
* @return array<string, mixed>
*/
private static function emptyTarget(string $yearMonth, float $completedAmount, float $personalAmount): array
{
return [
'year_month' => $yearMonth,
'target_amount' => 0.0,
'completed_amount' => round($completedAmount, 2),
'completion_rate' => null,
'personal_amount' => round($personalAmount, 2),
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
'department_count' => 0,
];
}
}