'2026-10-10', 'dingtalk.worklog.penalty_excluded_user_ids' => [], 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Asia/Shanghai', ]); $submitted = []; $misses = [ 'jiangling' => ['2026-10-10', '2026-10-12', '2026-10-18'], 'qiuxiang' => ['2026-10-12', '2026-10-18'], ]; foreach (['jiangling' => '江陵', 'qiuxiang' => '秋香'] as $userId => $name) { for ($day = 10; $day <= 18; $day++) { $date = sprintf('2026-10-%02d', $day); if (! in_array($date, $misses[$userId], true)) { $submitted[] = [ 'creator_userid' => $userId, 'creator_name' => $name, 'statistics_date' => $date, ]; } } } $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('earliestStatisticsDate')->once()->andReturn('2026-10-01'); $repository->shouldReceive('submittedDatesByUser') ->once()->with('2026-10-10', '2026-10-18')->andReturn($submitted); $roster = Mockery::mock(FinanceRoster::class); $roster->shouldReceive('cachedFinanceMembers')->once()->andReturn([ ['userid' => 'jiangling', 'name' => '江陵'], ['userid' => 'qiuxiang', 'name' => '秋香'], ]); $attendance = Mockery::mock(DingAttendanceMirrorRepository::class); $attendance->shouldReceive('ready')->once()->andReturnTrue(); $attendance->shouldReceive('scheduleReady')->once()->andReturnTrue(); $attendance->shouldReceive('exemptDatesForUsers')->once()->andReturn([]); $attendance->shouldReceive('scheduledWorkDatesForUsers') ->once() ->with(['jiangling', 'qiuxiang'], '2026-10-10', '2026-10-18') ->andReturn($this->scheduledWorkDates( ['jiangling', 'qiuxiang'], '2026-10-10', '2026-10-18', )); $result = (new DingWorklogPenaltyCalculator($repository, $roster, $attendance))->summary( '2026-10-10', '2026-10-18', Carbon::parse('2026-10-20 10:00:00', 'Asia/Shanghai'), ); $this->assertSame('2026-10-10', $result['start_date']); $this->assertSame('2026-10-18', $result['end_date']); $this->assertSame('江陵', $result['members'][0]['name']); $this->assertSame(['2026-10-10', '2026-10-12', '2026-10-18'], $result['members'][0]['missed_dates']); $this->assertSame(3, $result['members'][0]['missed_count']); $this->assertSame('10 + 20 + 40', $result['members'][0]['penalty_formula']); $this->assertSame('70', $result['members'][0]['penalty_points']); $this->assertSame('秋香', $result['members'][1]['name']); $this->assertSame('10 + 20', $result['members'][1]['penalty_formula']); $this->assertSame('30', $result['members'][1]['penalty_points']); $this->assertSame('100', $result['total_points']); $this->assertSame('100', $result['total_points_formatted']); } public function test_dates_before_november_fourth_are_not_charged(): void { config([ 'dingtalk.worklog.penalty_start_date' => '2026-11-04', 'dingtalk.worklog.penalty_excluded_user_ids' => [], 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Asia/Shanghai', ]); $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('earliestStatisticsDate')->once()->andReturn('2026-10-21'); $repository->shouldReceive('submittedDatesByUser') ->once()->with('2026-11-04', '2026-11-04')->andReturn([]); $roster = Mockery::mock(FinanceRoster::class); $roster->shouldReceive('cachedFinanceMembers')->once()->andReturn([ ['userid' => 'jiangling', 'name' => '江陵'], ]); $attendance = Mockery::mock(DingAttendanceMirrorRepository::class); $attendance->shouldReceive('ready')->once()->andReturnTrue(); $attendance->shouldReceive('scheduleReady')->once()->andReturnTrue(); $attendance->shouldReceive('exemptDatesForUsers')->once()->andReturn([]); $attendance->shouldReceive('scheduledWorkDatesForUsers') ->once() ->with(['jiangling'], '2026-11-04', '2026-11-04') ->andReturn($this->scheduledWorkDates(['jiangling'], '2026-11-04', '2026-11-04')); $result = (new DingWorklogPenaltyCalculator($repository, $roster, $attendance))->summary( '2026-10-22', '2026-11-04', Carbon::parse('2026-11-05 13:00:00', 'Asia/Shanghai'), ); $this->assertSame('2026-11-04', $result['start_date']); $this->assertSame('2026-11-04', $result['end_date']); $this->assertSame(['2026-11-04'], $result['members'][0]['missed_dates']); $this->assertSame('10', $result['total_points']); } public function test_excluded_members_do_not_receive_penalty_points(): void { config([ 'dingtalk.worklog.penalty_start_date' => '2026-11-04', 'dingtalk.worklog.penalty_excluded_user_ids' => [ '02591267217229', '17097754648162977', ], 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Asia/Shanghai', ]); $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('earliestStatisticsDate')->once()->andReturn('2026-11-04'); $repository->shouldReceive('submittedDatesByUser') ->once()->with('2026-11-04', '2026-11-04')->andReturn([]); $roster = Mockery::mock(FinanceRoster::class); $roster->shouldReceive('cachedFinanceMembers')->once()->andReturn([ ['userid' => '02591267217229', 'name' => '江陵'], ['userid' => '17097754648162977', 'name' => '周生辰'], ['userid' => 'qiuxiang', 'name' => '秋香'], ]); $attendance = Mockery::mock(DingAttendanceMirrorRepository::class); $attendance->shouldReceive('ready')->once()->andReturnTrue(); $attendance->shouldReceive('scheduleReady')->once()->andReturnTrue(); $attendance->shouldReceive('exemptDatesForUsers')->once()->andReturn([]); $attendance->shouldReceive('scheduledWorkDatesForUsers') ->once() ->with(['qiuxiang'], '2026-11-04', '2026-11-04') ->andReturn($this->scheduledWorkDates(['qiuxiang'], '2026-11-04', '2026-11-04')); $result = (new DingWorklogPenaltyCalculator($repository, $roster, $attendance))->summary( '2026-11-04', '2026-11-04', Carbon::parse('2026-11-05 13:00:00', 'Asia/Shanghai'), ); $this->assertSame(['秋香'], array_column($result['members'], 'name')); $this->assertSame('10', $result['total_points']); } public function test_approved_leave_or_rest_days_are_not_charged(): void { config([ 'dingtalk.worklog.penalty_start_date' => '2026-11-04', 'dingtalk.worklog.penalty_excluded_user_ids' => [], 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Asia/Shanghai', ]); $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('earliestStatisticsDate')->once()->andReturn('2026-11-04'); $repository->shouldReceive('submittedDatesByUser') ->once()->with('2026-11-04', '2026-11-04')->andReturn([]); $roster = Mockery::mock(FinanceRoster::class); $roster->shouldReceive('cachedFinanceMembers')->once()->andReturn([ ['userid' => 'changyi', 'name' => '常仪'], ['userid' => 'yaoyao', 'name' => '杳杳'], ]); $attendance = Mockery::mock(DingAttendanceMirrorRepository::class); $attendance->shouldReceive('ready')->once()->andReturnTrue(); $attendance->shouldReceive('scheduleReady')->once()->andReturnTrue(); $attendance->shouldReceive('exemptDatesForUsers') ->once() ->with(['changyi', 'yaoyao'], '2026-11-04', '2026-11-04') ->andReturn([ 'changyi' => ['2026-11-04' => true], 'yaoyao' => ['2026-11-04' => true], ]); $attendance->shouldReceive('scheduledWorkDatesForUsers') ->once() ->with(['changyi', 'yaoyao'], '2026-11-04', '2026-11-04') ->andReturn($this->scheduledWorkDates(['changyi', 'yaoyao'], '2026-11-04', '2026-11-04')); $result = (new DingWorklogPenaltyCalculator($repository, $roster, $attendance))->summary( '2026-11-04', '2026-11-04', Carbon::parse('2026-11-05 13:00:00', 'Asia/Shanghai'), ); $this->assertSame([], $result['members']); $this->assertSame('0', $result['total_points']); } public function test_company_rest_day_is_not_charged_without_log_or_leave(): void { config([ 'dingtalk.worklog.penalty_start_date' => '2026-11-04', 'dingtalk.worklog.penalty_excluded_user_ids' => [], 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Asia/Shanghai', ]); $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('earliestStatisticsDate')->once()->andReturn('2026-11-04'); $repository->shouldReceive('submittedDatesByUser') ->once()->with('2026-11-08', '2026-11-08')->andReturn([]); $roster = Mockery::mock(FinanceRoster::class); $roster->shouldReceive('cachedFinanceMembers')->once()->andReturn([ ['userid' => 'renshen', 'name' => '人参'], ['userid' => 'linchong', 'name' => '林冲'], ]); $attendance = Mockery::mock(DingAttendanceMirrorRepository::class); $attendance->shouldReceive('ready')->once()->andReturnTrue(); $attendance->shouldReceive('scheduleReady')->once()->andReturnTrue(); $attendance->shouldReceive('exemptDatesForUsers')->once()->andReturn([]); $attendance->shouldReceive('scheduledWorkDatesForUsers')->once()->andReturn([]); $result = (new DingWorklogPenaltyCalculator($repository, $roster, $attendance))->summary( '2026-11-08', '2026-11-08', Carbon::parse('2026-11-10 10:00:00', 'Asia/Shanghai'), ); $this->assertSame([], $result['members']); $this->assertSame('0', $result['total_points']); } public function test_worklog_timezone_does_not_follow_company_timezone(): void { config([ 'dingtalk.worklog.penalty_start_date' => '2026-11-04', 'dingtalk.worklog.penalty_excluded_user_ids' => [], 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Europe/London', 'app.platform_timezone' => 'Asia/Shanghai', ]); $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('earliestStatisticsDate')->once()->andReturn('2026-11-04'); $repository->shouldReceive('submittedDatesByUser') ->once()->with('2026-11-10', '2026-11-10')->andReturn([]); $roster = Mockery::mock(FinanceRoster::class); $roster->shouldReceive('cachedFinanceMembers')->once()->andReturn([ ['userid' => 'shanshi', 'name' => '山时'], ]); $attendance = Mockery::mock(DingAttendanceMirrorRepository::class); $attendance->shouldReceive('ready')->once()->andReturnTrue(); $attendance->shouldReceive('scheduleReady')->once()->andReturnTrue(); $attendance->shouldReceive('exemptDatesForUsers')->once()->andReturn([]); $attendance->shouldReceive('scheduledWorkDatesForUsers') ->once() ->with(['shanshi'], '2026-11-10', '2026-11-10') ->andReturn(['shanshi' => ['2026-11-10' => true]]); $result = (new DingWorklogPenaltyCalculator($repository, $roster, $attendance))->summary( '2026-11-10', '2026-11-10', Carbon::parse('2026-11-11 18:51:00', 'Asia/Shanghai'), ); $this->assertSame(['2026-11-10'], $result['members'][0]['missed_dates']); $this->assertSame('10', $result['total_points']); } /** @param list $userIds */ private function scheduledWorkDates(array $userIds, string $from, string $to): array { $dates = []; for ($date = Carbon::parse($from); $date->lessThanOrEqualTo(Carbon::parse($to)); $date->addDay()) { foreach ($userIds as $userId) { $dates[$userId][$date->toDateString()] = true; } } return $dates; } }