- 工作台新增「钉钉机器人设置」:Webhook、加签密钥加密存公司设置,页面不回显;支持自定义关键词、汇总单独发群、@ 报销人花名 - 核对后点「发取消通知到钉钉群」:按发起人一人一条,按钉钉花名册(花名 → userid)@ 发起人;同一批已发过的不重复发,可勾选重发 - 新增 planting-reimbursement:daily-digest,每天 10:25(宿主机 cron)生成当天批次,把待核对条数/金额、可报销合计、取消/待复查数、自检结果和工作台链接推到群里;数据停更或看板未上传会提醒 - 种菜监控看板提示「职级为空」的单改为取消工单,群通知原因写「职级为空」(2026-11-13 报销人确认) - 没上传花名册时,收款人在职改按钉钉花名册在职真名判断;两边都取不到时不做该项校验并提示,避免误判 涉及:只读聚工单镜像与钉钉花名册副本;钉钉群机器人发消息;不涉及迁移,不写聚工单。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
105 lines
5.4 KiB
PHP
105 lines
5.4 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Common\Company;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||
use Modules\EcommerceFinance\Services\PlantingReimbursementAuditor as Auditor;
|
||
use Modules\EcommerceFinance\Services\PlantingReimbursementRobot as Robot;
|
||
use Modules\EcommerceFinance\Services\PlantingReimbursementWorkbench as Workbench;
|
||
use Throwable;
|
||
|
||
/** 每天 10:25 生成当天种菜报销批次,把核对数、可报销合计、要取消的单和自检结果推到钉钉群并 @ 报销人。只读,不改聚工单。 */
|
||
class PlantingReimbursementDailyDigest extends Command
|
||
{
|
||
protected $signature = 'planting-reimbursement:daily-digest
|
||
{--date= : 报销日期 Y-m-d,默认今天}
|
||
{--dry-run : 只打印消息,不发送}
|
||
{--force : 忽略当天已推送标记}';
|
||
|
||
protected $description = '推送当天种菜报销批次汇总到钉钉群';
|
||
|
||
public function handle(Workbench $workbench, Robot $robot, Repository $repository): int
|
||
{
|
||
$date = trim((string) $this->option('date')) ?: Workbench::defaultDate();
|
||
try {
|
||
Workbench::parseDate($date);
|
||
} catch (Throwable $exception) {
|
||
$this->error($exception->getMessage());
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$failed = false;
|
||
foreach (Company::enabled()->orderBy('id')->get() as $company) {
|
||
$company->makeCurrent();
|
||
if (! $robot->enabled()) {
|
||
$this->line("#{$company->id}: 未配置种菜报销群机器人,跳过");
|
||
continue;
|
||
}
|
||
$lock = "planting-reimbursement:digest:{$company->id}:{$date}";
|
||
if (! $this->option('dry-run') && ! $this->option('force') && ! Cache::add($lock, now()->toDateTimeString(), now()->addDay())) {
|
||
$this->line("#{$company->id}: {$date} 已推送过");
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
[$title, $text] = $this->message($workbench, $repository, (int) $company->id, $date);
|
||
if ($this->option('dry-run')) {
|
||
$this->line($title . "\n" . $text);
|
||
continue;
|
||
}
|
||
$robot->sendDigest($title, $text);
|
||
$this->info("#{$company->id}: 已推送 {$date} 汇总");
|
||
} catch (Throwable $exception) {
|
||
Cache::forget($lock);
|
||
report($exception);
|
||
$this->error("#{$company->id}: 推送失败:" . $exception->getMessage());
|
||
$failed = true;
|
||
}
|
||
}
|
||
|
||
return $failed ? self::FAILURE : self::SUCCESS;
|
||
}
|
||
|
||
/** @return array{0:string,1:string} */
|
||
private function message(Workbench $workbench, Repository $repository, int $companyId, string $date): array
|
||
{
|
||
$sync = $repository->syncStatus();
|
||
$url = route('business.pages.show', ['company_id' => $companyId, 'section' => 'planting-data', 'page' => 'planting-reimbursement-workbench', 'date' => $date]);
|
||
$title = '种菜报销 ' . $date . ' 批次';
|
||
$lines = ['#### ' . $title];
|
||
|
||
$stale = $sync['code'] !== 'ready' || $sync['last_succeeded_at'] === null
|
||
|| now()->diffInMinutes(\Illuminate\Support\Carbon::parse($sync['last_succeeded_at']), true) > 20;
|
||
if ($stale) {
|
||
$lines[] = '> **聚工单数据不是最新的**(最后同步成功:' . ($sync['last_succeeded_at'] ? substr($sync['last_succeeded_at'], 0, 16) : '无') . '),先别按下面结果报销。';
|
||
}
|
||
if (! in_array($sync['code'], ['ready', 'failed'], true)) {
|
||
$lines[] = '- 聚工单镜像未就绪,暂时无法生成批次。';
|
||
$lines[] = '- [打开工作台](' . $url . ')';
|
||
|
||
return [$title, implode("\n", $lines)];
|
||
}
|
||
|
||
$result = $workbench->build($companyId, $date);
|
||
$s = $result['summary'];
|
||
$lines[] = '- 待处理(付款 ' . substr($result['window']['from'], 0, 10) . ' ~ ' . substr($result['window']['to'], 0, 10) . '):**' . $s['source']['count'] . ' 条 / ' . number_format((float) $s['source']['amount'], 2) . ' 元**,请先在聚工单核对';
|
||
$lines[] = '- 可报销:' . $s['counts'][Auditor::DECISION_PASS] . ' 条 / **' . number_format((float) $s['total'], 2) . ' 元**(三大天猫 ' . number_format((float) $s['group_amounts'][Auditor::GROUP_BIG_TMALL], 2) . ',其他 ' . number_format((float) $s['group_amounts'][Auditor::GROUP_OTHER], 2) . ')';
|
||
$lines[] = '- 取消工单:' . $s['counts'][Auditor::DECISION_CANCEL] . ' 条;待复查:' . $s['counts'][Auditor::DECISION_RECHECK] . ' 条';
|
||
$bad = array_column(array_filter($s['checks'], static fn (array $check): bool => ! $check['ok']), 'label');
|
||
$lines[] = '- 自检:' . ($bad === [] ? '全部通过' : '未通过 ' . implode('、', $bad));
|
||
if (($result['uploads']['monitor'] ?? null) === null || substr((string) ($result['uploads']['monitor']['uploaded_at'] ?? ''), 0, 10) !== $date) {
|
||
$lines[] = '- 今天还没上传种菜监控看板,超额尚未校验';
|
||
}
|
||
if (($result['roster_source'] ?? '') === 'none') {
|
||
$lines[] = '- 取不到在职名单,收款人在职未校验';
|
||
}
|
||
$lines[] = '- [打开工作台核对](' . $url . ')';
|
||
|
||
return [$title, implode("\n", $lines)];
|
||
}
|
||
}
|