CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementRobot.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

239 lines
11 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 Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
/**
* 种菜报销钉钉群机器人:给「种菜对接问题处理群」按发起人发取消通知(@ 发起人),每天 10:30 推送批次汇总。
*
* Webhook、加签密钥加密存在公司设置里,不进代码和配置文件。已发过的通知按日期记在 storage,避免重复发。
*/
class PlantingReimbursementRobot
{
public const SETTING_KEY = 'planting_reimbursement.robot';
public function __construct(private readonly PlantingReimbursementDirectory $directory) {}
/** @return array{webhook:string,secret:string,keyword:string,reporters:list<string>,digest_webhook:string,digest_secret:string} */
public function config(): array
{
$value = setting(self::SETTING_KEY, []);
if (is_string($value)) {
$value = json_decode($value, true) ?: [];
}
$value = is_array($value) ? $value : [];
return [
'webhook' => $this->decrypt($value['webhook'] ?? ''),
'secret' => $this->decrypt($value['secret'] ?? ''),
'keyword' => trim((string) ($value['keyword'] ?? '')),
'reporters' => array_values(array_filter(array_map('trim', (array) ($value['reporters'] ?? [])))),
'digest_webhook' => $this->decrypt($value['digest_webhook'] ?? ''),
'digest_secret' => $this->decrypt($value['digest_secret'] ?? ''),
];
}
public function enabled(): bool
{
return $this->config()['webhook'] !== '';
}
/** 页面展示用:不回显 Webhook 和密钥。 */
public function summary(): array
{
$config = $this->config();
return [
'configured' => $config['webhook'] !== '',
'webhook' => $this->mask($config['webhook']),
'has_secret' => $config['secret'] !== '',
'keyword' => $config['keyword'],
'reporters' => implode(',', $config['reporters']),
'digest_webhook' => $this->mask($config['digest_webhook']),
'has_digest_secret' => $config['digest_secret'] !== '',
];
}
/**
* 保存设置。Webhook / 密钥留空表示沿用原值。
*
* @param array{webhook?:string,secret?:string,keyword?:string,reporters?:string,digest_webhook?:string,digest_secret?:string,clear_digest?:bool} $input
*/
public function save(array $input): void
{
$current = $this->config();
$webhook = trim((string) ($input['webhook'] ?? '')) ?: $current['webhook'];
$this->assertWebhook($webhook, 'webhook');
$secret = trim((string) ($input['secret'] ?? '')) ?: $current['secret'];
$digestWebhook = ! empty($input['clear_digest']) ? '' : (trim((string) ($input['digest_webhook'] ?? '')) ?: $current['digest_webhook']);
if ($digestWebhook !== '') {
$this->assertWebhook($digestWebhook, 'digest_webhook');
}
$digestSecret = ! empty($input['clear_digest']) ? '' : (trim((string) ($input['digest_secret'] ?? '')) ?: $current['digest_secret']);
$reporters = array_values(array_unique(array_filter(array_map('trim', preg_split('/[,,、\s]+/u', (string) ($input['reporters'] ?? '')) ?: []))));
foreach (['secret' => $secret, 'digest_secret' => $digestSecret] as $field => $value) {
if (mb_strlen($value) > 256) {
throw ValidationException::withMessages([$field => '加签密钥过长。']);
}
}
setting()->set(self::SETTING_KEY, json_encode([
'webhook' => Crypt::encryptString($webhook),
'secret' => $secret !== '' ? Crypt::encryptString($secret) : '',
'keyword' => mb_substr(trim((string) ($input['keyword'] ?? '')), 0, 50),
'reporters' => array_slice($reporters, 0, 20),
'digest_webhook' => $digestWebhook !== '' ? Crypt::encryptString($digestWebhook) : '',
'digest_secret' => $digestSecret !== '' ? Crypt::encryptString($digestSecret) : '',
], JSON_UNESCAPED_UNICODE));
setting()->save();
}
/**
* 按发起人发取消 / 待复查通知(一人一条,@ 发起人)。同一发起人、同一批单已发过的不再重发。
*
* @param list<array{initiator:string,items:list<array<string,string>>,message:string}> $notices
* @return array{sent:int,skipped:int,failed:int,unmatched:list<string>}
*/
public function sendNotices(int $companyId, string $date, array $notices, bool $force = false): array
{
$config = $this->config();
if ($config['webhook'] === '') {
throw new RuntimeException('还没配置钉钉群机器人。');
}
$log = $this->noticeLog($companyId, $date);
$userIds = $this->directory->userIds(array_column($notices, 'initiator'));
$result = ['sent' => 0, 'skipped' => 0, 'failed' => 0, 'unmatched' => []];
foreach ($notices as $notice) {
$initiator = $notice['initiator'];
$fingerprint = sha1(implode(',', array_map(static fn (array $item): string => $item['order_no'] . ':' . $item['decision'], $notice['items'])));
if (! $force && ($log[$initiator]['fingerprint'] ?? null) === $fingerprint) {
$result['skipped']++;
continue;
}
$userId = $userIds[$initiator] ?? null;
if ($userId === null) {
$result['unmatched'][] = $initiator;
}
$text = "#### 种菜报销 " . $date . " 不予报销通知\n\n"
. implode("\n", array_map(static fn (array $item): string => '- ' . $item['order_no'] . ':' . $item['reason'], $notice['items']))
. "\n\n" . ($userId !== null ? '@' . $userId : '@' . $initiator) . ' 以上订单本次不予报销,工单已取消(待复查的单稍后复核)。';
try {
$this->send($config['webhook'], $config['secret'], $config['keyword'], '种菜报销不予报销通知', $text, $userId !== null ? [$userId] : []);
$log[$initiator] = ['fingerprint' => $fingerprint, 'sent_at' => now()->format('Y-m-d H:i:s'), 'count' => count($notice['items'])];
$result['sent']++;
} catch (Throwable $exception) {
report($exception);
$result['failed']++;
}
}
$this->saveNoticeLog($companyId, $date, $log);
return $result;
}
/** @return array<string,array{fingerprint:string,sent_at:string,count:int}> 已发通知(按发起人) */
public function noticeLog(int $companyId, string $date): array
{
$path = $this->noticePath($companyId, $date);
$disk = Storage::disk('local');
$log = $disk->exists($path) ? json_decode((string) $disk->get($path), true) : [];
return is_array($log) ? $log : [];
}
/** 每日批次汇总:发到汇总群(未单独配置则发到通知群),@ 报销人。 */
public function sendDigest(string $title, string $text): void
{
$config = $this->config();
$webhook = $config['digest_webhook'] ?: $config['webhook'];
$secret = $config['digest_webhook'] !== '' ? $config['digest_secret'] : $config['secret'];
if ($webhook === '') {
throw new RuntimeException('还没配置钉钉群机器人。');
}
$userIds = array_values($this->directory->userIds($config['reporters']));
if ($userIds !== []) {
$text .= "\n\n" . implode(' ', array_map(static fn (string $id): string => '@' . $id, $userIds));
}
$this->send($webhook, $secret, $config['keyword'], $title, $text, $userIds);
}
/** @param list<string> $atUserIds */
private function send(string $webhook, string $secret, string $keyword, string $title, string $text, array $atUserIds): void
{
if ($keyword !== '' && ! str_contains($title . $text, $keyword)) {
$text .= "\n\n(" . $keyword . ')';
}
$url = $webhook;
if ($secret !== '') {
$timestamp = (string) (int) (microtime(true) * 1000);
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
}
$response = Http::timeout(10)->post($url, [
'msgtype' => 'markdown',
'markdown' => ['title' => $title, 'text' => $text],
'at' => ['atUserIds' => array_values($atUserIds), 'atMobiles' => [], 'isAtAll' => false],
]);
if (! $response->successful()) {
throw new RuntimeException('钉钉机器人推送返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException('钉钉机器人推送失败:' . ($body['errcode'] ?? '?') . ' ' . ($body['errmsg'] ?? ''));
}
}
private function saveNoticeLog(int $companyId, string $date, array $log): void
{
Storage::disk('local')->put($this->noticePath($companyId, $date), json_encode($log, JSON_UNESCAPED_UNICODE));
}
private function noticePath(int $companyId, string $date): string
{
return PlantingReimbursementReferences::DIRECTORY . '/' . $companyId . '/notices/' . preg_replace('/[^0-9-]/', '', $date) . '.json';
}
private function assertWebhook(string $webhook, string $field): void
{
$parts = parse_url($webhook);
parse_str((string) ($parts['query'] ?? ''), $query);
if (($parts['scheme'] ?? '') !== 'https'
|| strtolower((string) ($parts['host'] ?? '')) !== 'oapi.dingtalk.com'
|| ($parts['path'] ?? '') !== '/robot/send'
|| trim((string) ($query['access_token'] ?? '')) === '') {
throw ValidationException::withMessages([$field => '请填写有效的钉钉群机器人 Webhook(https://oapi.dingtalk.com/robot/send?access_token= 开头)。']);
}
}
private function mask(string $webhook): string
{
return $webhook === '' ? '' : 'https://oapi.dingtalk.com/robot/send?access_token=••••••' . substr($webhook, -4);
}
private function decrypt(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
try {
return Crypt::decryptString($value);
} catch (Throwable) {
return '';
}
}
}