fix: 日志统计纳入财务中心空白日志 #883

Merged
renshen merged 1 commits from fix/renshen-ding-worklog-blank-template into main 2026-11-05 11:19:09 +08:00
6 changed files with 172 additions and 12 deletions

View File

@ -2,7 +2,9 @@
namespace App\Console\Commands;
use App\Services\Auth\FinanceRoster;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Exceptions\DingTalkWorklogException;
use Modules\EcommerceFinance\Repositories\DingWorklogMirrorRepository;
use Modules\EcommerceFinance\Services\DingTalkWorklogClient;
@ -19,6 +21,7 @@ class SyncDingWorklogs extends Command
public function handle(
DingTalkWorklogClient $client,
DingWorklogMirrorRepository $repository,
FinanceRoster $financeRoster,
): int {
if (! (bool) config('dingtalk.worklog.enabled', true)) {
return self::SUCCESS;
@ -36,16 +39,41 @@ class SyncDingWorklogs extends Command
}
$days = max((int) ($this->option('days') ?: config('dingtalk.worklog.lookback_days', 35)), 1);
$template = trim((string) ($this->option('template') ?: config('dingtalk.worklog.template_name', '财务日报')));
$templates = $this->templates();
$to = now();
$from = $to->copy()->subDays($days)->startOfDay();
try {
$result = $client->listReports($from, $to, $template);
$now = now();
$pages = 0;
$reports = [];
foreach ($templates as $template) {
$result = $client->listReports($from, $to, $template);
$pages += (int) $result['pages'];
foreach ($result['reports'] as $report) {
if (! is_array($report)) {
continue;
}
$reportId = DingWorklogMirrorRepository::reportId($report);
$reports[$reportId] = [
'requested_template' => $template,
'report' => $report,
];
}
}
$financeOnlyTemplates = array_values(array_filter(array_map(
'trim',
(array) config('dingtalk.worklog.finance_only_templates', ['空白日志']),
)));
$financeMembers = [];
$rows = [];
foreach ($result['reports'] as $report) {
if (! is_array($report)) {
foreach ($reports as $reportId => $entry) {
$report = $entry['report'];
$templateName = trim((string) ($report['template_name'] ?? $entry['requested_template']));
$creatorUserId = mb_substr(trim((string) ($report['creator_userid'] ?? $report['creator_id'] ?? '')), 0, 128);
if (in_array($templateName, $financeOnlyTemplates, true)
&& ! $this->isFinanceMember($creatorUserId, $financeRoster, $financeMembers)) {
continue;
}
$submittedRaw = $report['create_time']
@ -63,9 +91,9 @@ class SyncDingWorklogs extends Command
}
$statisticsDate = DingWorklogMirrorRepository::statisticsDate($submittedAt);
$rows[] = [
'report_id' => DingWorklogMirrorRepository::reportId($report),
'template_name' => mb_substr(trim((string) ($report['template_name'] ?? $template)), 0, 191),
'creator_userid' => mb_substr(trim((string) ($report['creator_userid'] ?? $report['creator_id'] ?? '')), 0, 128),
'report_id' => $reportId,
'template_name' => mb_substr($templateName, 0, 191),
'creator_userid' => $creatorUserId,
'creator_name' => mb_substr(trim((string) ($report['creator_name'] ?? $report['creator_nick'] ?? '')), 0, 191),
'submitted_at' => $submittedAt->toDateTimeString(),
'statistics_date' => $statisticsDate->toDateString(),
@ -78,11 +106,11 @@ class SyncDingWorklogs extends Command
}
$upserted = $repository->upsertReports($rows);
$repository->recordSuccess($upserted, $template, $from, $to);
$repository->recordSuccess($upserted, implode(' / ', $templates), $from, $to);
$this->info(sprintf(
'钉钉日志同步完成:读取 %d 页,写入/更新 %d 条。',
$result['pages'],
$pages,
$upserted,
));
@ -101,4 +129,46 @@ class SyncDingWorklogs extends Command
return self::FAILURE;
}
}
/** @return list<string> */
private function templates(): array
{
$configured = trim((string) $this->option('template'));
$templates = $configured !== ''
? preg_split('/[,,]/u', $configured, -1, PREG_SPLIT_NO_EMPTY)
: (array) config('dingtalk.worklog.template_names', [
config('dingtalk.worklog.template_name', '财务日报'),
]);
$templates = array_values(array_unique(array_filter(array_map(
static fn (mixed $template): string => trim((string) $template),
$templates,
))));
return $templates !== [] ? $templates : ['财务日报'];
}
/**
* “空白日志”是全公司共用模板,只统计财务中心成员的记录。
*
* @param array<string,bool> $members
*/
private function isFinanceMember(string $userId, FinanceRoster $roster, array &$members): bool
{
if ($userId === '') {
return false;
}
if (array_key_exists($userId, $members)) {
return $members[$userId];
}
return $members[$userId] = (bool) Cache::remember(
'dingtalk:worklog:finance-member:' . hash('sha256', $userId),
now()->addMinutes(max((int) config('dingtalk.worklog.finance_cache_minutes', 720), 1)),
static function () use ($roster, $userId): bool {
$member = $roster->findEligible($userId);
return $member !== null && empty($member['restricted']);
},
);
}
}

View File

@ -46,10 +46,20 @@ return [
'worklog' => [
'enabled' => filter_var(env('DINGTALK_WORKLOG_ENABLED', true), FILTER_VALIDATE_BOOL),
'template_name' => env('DINGTALK_WORKLOG_TEMPLATE_NAME', '财务日报'),
'template_names' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('DINGTALK_WORKLOG_TEMPLATE_NAMES', '财务日报,空白日志')),
))),
'finance_only_templates' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('DINGTALK_WORKLOG_FINANCE_ONLY_TEMPLATES', '空白日志')),
))),
'display_name' => env('DINGTALK_WORKLOG_DISPLAY_NAME', '财务日报 / 日志'),
'userid' => env('DINGTALK_WORKLOG_USERID'),
'lookback_days' => (int) env('DINGTALK_WORKLOG_LOOKBACK_DAYS', 35),
'page_size' => min(max((int) env('DINGTALK_WORKLOG_PAGE_SIZE', 20), 1), 20),
'max_pages' => max((int) env('DINGTALK_WORKLOG_MAX_PAGES', 100), 1),
'finance_cache_minutes' => max((int) env('DINGTALK_WORKLOG_FINANCE_CACHE_MINUTES', 720), 1),
'permission_scope' => 'qyapi_report_query',
],
];

View File

@ -117,7 +117,10 @@ class DingWorklogMirrorRepository
$state = $this->state();
$values = [
'source_key' => 'dingtalk_worklog',
'source_object' => (string) config('dingtalk.worklog.template_name', '财务日报'),
'source_object' => implode(' / ', (array) config(
'dingtalk.worklog.template_names',
[config('dingtalk.worklog.template_name', '财务日报')],
)),
'target_table' => self::TABLE,
'status' => 'failed',
'run_mode' => 'incremental',

View File

@ -93,7 +93,7 @@ class DingWorklogStatisticsPageData
'worklogSubmissions' => $submissions,
'worklogDays' => (int) $from->diffInDays($to) + 1,
'worklogTotalSubmissions' => array_sum(array_column($daily, 'submission_count')),
'worklogTemplateName' => (string) config('dingtalk.worklog.template_name', '财务日报'),
'worklogTemplateName' => (string) config('dingtalk.worklog.display_name', '财务日报 / 日志'),
'worklogCutoffAt' => $latestStatisticsDate->copy()->addDay()->setTime(12, 0)->toDateTimeString(),
];
}

View File

@ -139,7 +139,7 @@ class PagesTest extends FeatureTestCase
]))
->assertOk()
->assertSee('data-log-statistics', false)
->assertSeeText('钉钉 财务日报 · 次日 12:00 截止')
->assertSeeText('钉钉 财务日报 / 日志 · 次日 12:00 截止')
->assertSeeText('次日 12:00 前补交的日志,统一计入统计日')
->assertSeeText('qyapi_report_query')
->assertSeeText('前往钉钉开通')

View File

@ -2,8 +2,10 @@
namespace Tests\Feature\Console;
use App\Services\Auth\FinanceRoster;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
@ -26,9 +28,12 @@ class SyncDingWorklogsTest extends TestCase
'app.timezone' => 'Asia/Shanghai',
'dingtalk.worklog.enabled' => true,
'dingtalk.worklog.template_name' => '财务日报',
'dingtalk.worklog.template_names' => ['财务日报'],
'dingtalk.worklog.finance_only_templates' => ['空白日志'],
'dingtalk.worklog.lookback_days' => 35,
]);
DB::purge('business_mirror');
Cache::flush();
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key', 191)->primary();
@ -119,4 +124,76 @@ class SyncDingWorklogsTest extends TestCase
Carbon::setTestNow();
}
}
public function test_blank_logs_are_included_only_for_finance_members(): void
{
Carbon::setTestNow(Carbon::parse('2026-11-05 13:00:00', 'Asia/Shanghai'));
config(['dingtalk.worklog.template_names' => ['财务日报', '空白日志']]);
try {
$client = Mockery::mock(DingTalkWorklogClient::class);
$client->shouldReceive('isConfigured')->once()->andReturnTrue();
$client->shouldReceive('listReports')
->once()
->withArgs(static fn ($from, $to, string $template): bool => $template === '财务日报')
->andReturn([
'pages' => 1,
'reports' => [[
'report_id' => 'finance-daily',
'creator_id' => 'user-finance-daily',
'creator_name' => '秋香',
'template_name' => '财务日报',
'create_time' => '2026-11-04 22:14:16',
]],
]);
$client->shouldReceive('listReports')
->once()
->withArgs(static fn ($from, $to, string $template): bool => $template === '空白日志')
->andReturn([
'pages' => 1,
'reports' => [
[
'report_id' => 'finance-blank-log',
'creator_id' => 'user-renshen',
'creator_name' => '人参',
'template_name' => '空白日志',
'create_time' => '2026-11-04 23:00:00',
],
[
'report_id' => 'other-department-blank-log',
'creator_id' => 'user-other',
'creator_name' => '其他人',
'template_name' => '空白日志',
'create_time' => '2026-11-04 23:10:00',
],
],
]);
$this->app->instance(DingTalkWorklogClient::class, $client);
$roster = Mockery::mock(FinanceRoster::class);
$roster->shouldReceive('findEligible')->once()->with('user-renshen')
->andReturn(['restricted' => false]);
$roster->shouldReceive('findEligible')->once()->with('user-other')
->andReturn(['restricted' => true]);
$this->app->instance(FinanceRoster::class, $roster);
$this->artisan('business-mirror:sync-ding-worklogs')
->expectsOutputToContain('写入/更新 2 条')
->assertSuccessful();
$this->assertSame(
['finance-blank-log', 'finance-daily'],
DB::connection('business_mirror')->table('ding_worklog_reports')
->orderBy('report_id')->pluck('report_id')->all(),
);
$this->assertSame(
'财务日报 / 空白日志',
DB::connection('business_mirror')->table('business_mirror_table_states')
->where('dataset_key', 'dingtalk.worklog.finance_daily_reports')
->value('source_object'),
);
} finally {
Carbon::setTestNow();
}
}
}