CaiWuXiTong/app/Console/Commands/PlantingReimbursementDailyDigest.php
limeng f78149e630 feat(种菜报销): 接入钉钉群机器人(取消通知 @ 发起人、每天 10:25 推送汇总),看板职级为空改为取消 (#1150)
- 工作台新增「钉钉机器人设置」: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
2026-11-13 11:15:04 +08:00

105 lines
5.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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)];
}
}