fix: 日志统计纳入财务中心空白日志 #883
@ -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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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('前往钉钉开通')
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user