shouldReceive('available')->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn([ 'status' => 'succeeded', 'last_attempted_at' => '2026-11-05 12:45:00', 'last_succeeded_at' => '2026-11-05 12:45:00', 'mirror_row_count' => 83, 'last_error_code' => null, ]); $repository->shouldReceive('dailySummary')->once()->andReturn([[ 'statistics_date' => '2026-11-04', 'submission_count' => 9, 'submitter_count' => 9, 'late_submission_count' => 1, 'first_submitted_at' => '2026-11-04 20:16:55', 'last_submitted_at' => '2026-11-05 11:32:59', ]]); $repository->shouldNotReceive('submissionsForDate'); $penalty = Mockery::mock(DingWorklogPenaltyCalculator::class); $penalty->shouldReceive('summary')->once()->andReturn([ 'start_date' => '2026-11-04', 'end_date' => '2026-11-04', 'total_points' => '40', 'total_points_formatted' => '40', 'members' => [], ]); $data = (new DingWorklogStatisticsPageData($repository, $penalty))->page( Request::create('/?from=2026-10-22&to=2026-11-04') ); $this->assertFalse($data['worklogShowDetails']); $this->assertSame([], $data['worklogSubmissions']); $this->assertSame(9, $data['worklogDaily'][0]['submitter_count']); } finally { Carbon::setTestNow(); } } public function test_late_submission_details_explain_the_cutoff_overrun(): void { Carbon::setTestNow(Carbon::parse('2026-11-12 13:00:00', 'Asia/Shanghai')); try { $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('available')->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn([ 'status' => 'succeeded', 'last_attempted_at' => '2026-11-12 12:45:00', 'last_succeeded_at' => '2026-11-12 12:45:00', 'mirror_row_count' => 131, 'last_error_code' => null, ]); $repository->shouldReceive('dailySummary')->once()->andReturn([[ 'statistics_date' => '2026-11-10', 'submission_count' => 10, 'submitter_count' => 10, 'late_submission_count' => 1, 'first_submitted_at' => '2026-11-10 20:08:43', 'last_submitted_at' => '2026-11-11 10:11:13', ]]); $repository->shouldReceive('submissionsForDate') ->once() ->with('2026-11-10') ->andReturn([ (object) [ 'report_id' => 'report-regular', 'template_name' => '财务日报', 'creator_userid' => 'user-regular', 'creator_name' => '苏木', 'submitted_at' => '2026-11-10 20:08:43', 'statistics_date' => '2026-11-10', ], ]); $repository->shouldReceive('lateSubmissionsForDate') ->once() ->with('2026-11-10') ->andReturn([ (object) [ 'report_id' => 'report-late', 'template_name' => '财务日报', 'creator_userid' => 'user-mountain', 'creator_name' => '山时', 'submitted_at' => '2026-11-11 12:11:33', 'statistics_date' => '2026-11-11', ], ]); $penalty = Mockery::mock(DingWorklogPenaltyCalculator::class); $penalty->shouldReceive('summary')->once()->andReturn([ 'start_date' => '2026-11-04', 'end_date' => '2026-11-10', 'total_points' => '20', 'total_points_formatted' => '20', 'members' => [], ]); $data = (new DingWorklogStatisticsPageData($repository, $penalty))->page( Request::create('/?from=2026-11-01&to=2026-11-10&date=2026-11-10&details=1&late=1') ); $this->assertTrue($data['worklogShowDetails']); $this->assertSame('苏木', $data['worklogSubmissions'][0]['creator_name']); $this->assertTrue($data['worklogShowLateDetails']); $this->assertSame(1, $data['worklogTotalLateSubmissions']); $this->assertSame('2026-11-10', $data['worklogLateSubmissions'][0]['late_for_date']); $this->assertSame('2026-11-11 12:00:00', $data['worklogLateSubmissions'][0]['deadline_at']); $this->assertSame('11 分 33 秒', $data['worklogLateSubmissions'][0]['late_duration']); } finally { Carbon::setTestNow(); } } public function test_page_uses_worklog_timezone_instead_of_company_timezone(): void { config([ 'dingtalk.worklog.timezone' => 'Asia/Shanghai', 'app.timezone' => 'Europe/London', 'app.platform_timezone' => 'Asia/Shanghai', ]); Carbon::setTestNow(Carbon::parse('2026-11-11 18:51:00', 'Asia/Shanghai')); try { $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('available')->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn([ 'status' => 'succeeded', 'last_attempted_at' => '2026-11-11 18:51:03', 'last_succeeded_at' => '2026-11-11 18:51:03', 'mirror_row_count' => 131, 'last_error_code' => null, ]); $repository->shouldReceive('dailySummary') ->once() ->with('2026-10-28', '2026-11-10') ->andReturn([]); $penalty = Mockery::mock(DingWorklogPenaltyCalculator::class); $penalty->shouldReceive('summary')->once()->andReturn([ 'start_date' => '2026-11-04', 'end_date' => '2026-11-10', 'total_points' => '20', 'total_points_formatted' => '20', 'members' => [], ]); $data = (new DingWorklogStatisticsPageData($repository, $penalty))->page( Request::create('/?from=2026-10-28&to=2026-11-10') ); $this->assertSame('2026-11-10', $data['worklogCurrent']['statistics_date']); $this->assertSame('2026-11-11 12:00:00', $data['worklogCutoffAt']); $this->assertSame('20', $data['worklogPenalty']['total_points']); } finally { Carbon::setTestNow(); } } public function test_schedule_not_ready_is_reported_without_calculating_penalties(): void { Carbon::setTestNow(Carbon::parse('2026-11-10 13:00:00', 'Asia/Shanghai')); try { $repository = Mockery::mock(DingWorklogMirrorRepository::class); $repository->shouldReceive('available')->andReturnTrue(); $repository->shouldReceive('state')->once()->andReturn([ 'status' => 'succeeded', 'last_attempted_at' => '2026-11-10 12:45:00', 'last_succeeded_at' => '2026-11-10 12:45:00', 'mirror_row_count' => 83, 'last_error_code' => null, ]); $repository->shouldReceive('dailySummary')->once()->andReturn([]); $repository->shouldNotReceive('submissionsForDate'); $penalty = Mockery::mock(DingWorklogPenaltyCalculator::class); $penalty->shouldReceive('summary')->once()->andThrow( new \RuntimeException('dingtalk_attendance_schedule_not_ready') ); $data = (new DingWorklogStatisticsPageData($repository, $penalty))->page( Request::create('/?from=2026-11-04&to=2026-11-08') ); $this->assertSame( '钉钉排班镜像尚未完成首次同步,无法判断公司工作日,扣分暂不计算。', $data['worklogPenaltyError'], ); $this->assertSame([], $data['worklogPenalty']['members']); } finally { Carbon::setTestNow(); } } }