CaiWuXiTong/modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php

331 lines
13 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\Validation\ValidationException;
use RuntimeException;
use Throwable;
/**
* 货款对账差异:按供应商保存钉钉群机器人,并推送当前比对差异。
*/
class SupplierDingTalkNotifier
{
private const SETTING_KEY = 'supplier_reconciliation.dingtalk_robots';
private const ROWS_PER_MESSAGE = 30;
private const MAX_MESSAGES = 20;
/** @return array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int} */
public function configuration(string $supplier): array
{
$config = $this->configs()[$supplier] ?? null;
$webhook = is_array($config) ? trim((string) ($config['webhook'] ?? '')) : '';
return [
'configured' => $webhook !== '',
'webhook_hint' => $this->maskWebhook($webhook),
'at_all' => (bool) ($config['at_all'] ?? false),
'last_pushed_at' => (string) ($config['last_pushed_at'] ?? ''),
'last_push_count' => (int) ($config['last_push_count'] ?? 0),
];
}
public function save(string $supplier, string $webhook, string $secret, bool $atAll): void
{
$configs = $this->configs();
$existing = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
$existingWebhook = trim((string) ($existing['webhook'] ?? ''));
$webhook = trim($webhook);
if ($webhook === '') {
$webhook = $existingWebhook;
}
if ($webhook === '') {
throw ValidationException::withMessages(['dingtalk_webhook' => '请填写钉钉群机器人 Webhook。']);
}
$this->assertWebhook($webhook);
$secret = trim($secret);
if ($secret === '' && $webhook === $existingWebhook) {
$secret = trim((string) ($existing['secret'] ?? ''));
}
if (mb_strlen($secret) > 256) {
throw ValidationException::withMessages(['dingtalk_secret' => '机器人加签密钥过长。']);
}
$configs[$supplier] = [
'webhook' => $webhook,
'secret' => $secret,
'at_all' => $atAll,
'last_pushed_at' => (string) ($existing['last_pushed_at'] ?? ''),
'last_push_count' => (int) ($existing['last_push_count'] ?? 0),
];
$this->persist($configs);
}
/**
* @param list<array<string,mixed>> $differences
* @param array<string,mixed> $filters
* @return array{messages:int,rows:int}
*/
public function push(string $supplier, array $differences, array $filters): array
{
if ($differences === []) {
throw ValidationException::withMessages(['dingtalk_push' => '当前筛选没有可推送的差异。']);
}
$configs = $this->configs();
$config = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw ValidationException::withMessages(['dingtalk_push' => '该厂家还没有配置钉钉群机器人 Webhook。']);
}
$this->assertWebhook($webhook);
$chunks = array_chunk(array_values($differences), self::ROWS_PER_MESSAGE);
if (count($chunks) > self::MAX_MESSAGES) {
throw ValidationException::withMessages([
'dingtalk_push' => '当前差异超过 ' . (self::ROWS_PER_MESSAGE * self::MAX_MESSAGES)
. ' 条,请先缩小月份或对账人范围后再分批推送。',
]);
}
$summary = $this->summarize($differences);
$total = max(count($chunks), 1);
foreach ($chunks as $index => $chunk) {
$title = $supplier . ' · 货款对账差异';
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$title,
$this->message($supplier, $chunk, $summary, $filters, $index + 1, $total, $index * self::ROWS_PER_MESSAGE),
);
}
$config['last_pushed_at'] = now()->format('Y-m-d H:i:s');
$config['last_push_count'] = count($differences);
$configs[$supplier] = $config;
$this->persist($configs);
return ['messages' => count($chunks), 'rows' => count($differences)];
}
/**
* @param list<array<string,mixed>> $rows
* @return array{count:int,extra:int,mismatch:int,missing:int,difference_amount:float}
*/
public function summarize(array $rows): array
{
$summary = ['count' => count($rows), 'extra' => 0, 'mismatch' => 0, 'missing' => 0, 'difference_amount' => 0.0];
foreach ($rows as $row) {
$type = (string) ($row['type'] ?? '');
$mine = (float) ($row['mineAmount'] ?? 0);
$theirs = (float) ($row['theirsAmount'] ?? 0);
if ($type === SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA) {
$summary['extra']++;
$summary['difference_amount'] += $theirs;
} elseif ($type === SupplierStatementMatcher::TYPE_MISMATCH) {
$summary['mismatch']++;
$summary['difference_amount'] += abs($mine - $theirs);
} elseif ($type === SupplierStatementMatcher::TYPE_SUPPLIER_MISSING) {
$summary['missing']++;
$summary['difference_amount'] += $mine;
}
}
$summary['difference_amount'] = round($summary['difference_amount'], 2);
return $summary;
}
/**
* @param list<array<string,mixed>> $rows
* @param array{count:int,extra:int,mismatch:int,missing:int,difference_amount:float} $summary
* @param array<string,mixed> $filters
*/
private function message(
string $supplier,
array $rows,
array $summary,
array $filters,
int $part,
int $parts,
int $rowOffset,
): string {
$month = trim((string) ($filters['month'] ?? ''));
$reconciler = trim((string) ($filters['reconciler'] ?? ''));
$lines = [
'### 货款对账差异通知 · ' . $supplier,
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
];
if ($parts > 1) {
$lines[] = '- 本条为第 ' . $part . ' / ' . $parts . ' 条,请结合后续消息一起核对。';
}
$lines[] = '';
foreach ($rows as $index => $row) {
$mineQty = (float) ($row['mineQty'] ?? 0);
$theirsQty = (float) ($row['theirsQty'] ?? 0);
$mineAmount = (float) ($row['mineAmount'] ?? 0);
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
$date = trim((string) ($row['date'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
$lines[] = sprintf(
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
$rowOffset + $index + 1,
(string) ($row['type'] ?? '差异'),
$purchaseNo !== '' ? $purchaseNo : '—',
$date !== '' ? $date : '—',
$barcode,
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
);
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
. '|单价 ¥' . number_format($mineUnitPrice, 2)
. '|金额 ¥' . number_format($mineAmount, 2);
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
. '|金额 ¥' . number_format($theirsAmount, 2);
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
}
$lines[] = '';
$lines[] = '> 请登录厂家对账门户核对并修改对账单;如已确认,请联系财务处理。';
$lines[] = '- [打开厂家对账门户](' . route('login') . ')';
return implode("\n", $lines);
}
private function send(string $webhook, string $secret, bool $atAll, string $title, string $text): void
{
$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' => ['atMobiles' => [], 'isAtAll' => $atAll],
]);
if (! $response->successful()) {
throw new RuntimeException('钉钉机器人推送返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException('钉钉机器人推送失败:' . (string) ($body['errcode'] ?? '?')
. ' ' . (string) ($body['errmsg'] ?? ''));
}
}
private function assertWebhook(string $webhook): void
{
$parts = parse_url($webhook);
parse_str((string) ($parts['query'] ?? ''), $query);
$host = strtolower((string) ($parts['host'] ?? ''));
if (($parts['scheme'] ?? '') !== 'https'
|| ! in_array($host, ['oapi.dingtalk.com', 'api.dingtalk.com'], true)
|| ($parts['path'] ?? '') !== '/robot/send'
|| trim((string) ($query['access_token'] ?? '')) === '') {
throw ValidationException::withMessages([
'dingtalk_webhook' => '请填写有效的钉钉群机器人 Webhook(必须以 https://oapi.dingtalk.com/robot/send?access_token= 开头)。',
]);
}
}
private function maskWebhook(string $webhook): string
{
if ($webhook === '') {
return '';
}
$parts = parse_url($webhook);
$host = (string) ($parts['host'] ?? '');
return $host !== '' ? 'https://' . $host . '/robot/send?access_token=••••••' : '已配置';
}
/** @return array<string,array<string,mixed>> */
private function configs(): array
{
$value = setting(self::SETTING_KEY, []);
if (is_string($value)) {
$value = json_decode($value, true) ?: [];
}
if (! is_array($value)) {
return [];
}
foreach ($value as $supplier => &$config) {
if (! is_array($config)) {
unset($value[$supplier]);
continue;
}
$config['webhook'] = $this->decryptValue($config['webhook'] ?? '');
$config['secret'] = $this->decryptValue($config['secret'] ?? '');
}
unset($config);
return $value;
}
/** @param array<string,array<string,mixed>> $configs */
private function persist(array $configs): void
{
$stored = [];
foreach ($configs as $supplier => $config) {
$webhook = trim((string) ($config['webhook'] ?? ''));
$secret = trim((string) ($config['secret'] ?? ''));
$stored[$supplier] = [
'webhook' => $webhook !== '' ? Crypt::encryptString($webhook) : '',
'secret' => $secret !== '' ? Crypt::encryptString($secret) : '',
'at_all' => (bool) ($config['at_all'] ?? false),
'last_pushed_at' => (string) ($config['last_pushed_at'] ?? ''),
'last_push_count' => (int) ($config['last_push_count'] ?? 0),
];
}
setting()->set(self::SETTING_KEY, json_encode($stored, JSON_UNESCAPED_UNICODE));
setting()->save();
}
private function decryptValue(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
try {
return Crypt::decryptString($value);
} catch (Throwable) {
return $value;
}
}
private function number(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}