CaiWuXiTong/modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php
苏木 026733e919 fix: 修正退货确认回执北京时间 (#869)
回执和机器人最近推送时间改用平台时区,避免 UTC 导致北京时间少 8 小时。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-04 17:31:08 +08:00

511 lines
20 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;
public function __construct(
private readonly SupplierReturnShareLink $returnShareLinks,
) {
}
/** @return array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int} */
public function configuration(string $supplier): array
{
return $this->configurationFrom($this->configs()[$supplier] ?? null);
}
/**
* 一次读取配置并返回多个厂家的推送状态,避免供应商列表逐行查询。
*
* @param list<string> $suppliers
* @return array<string,array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int}>
*/
public function configurationMap(array $suppliers): array
{
$configs = $this->configs();
$result = [];
foreach (array_unique(array_filter(array_map('trim', $suppliers))) as $supplier) {
$result[$supplier] = $this->configurationFrom($configs[$supplier] ?? null);
}
return $result;
}
/**
* @return array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int}
*/
private function configurationFrom(mixed $config): array
{
$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'] = $this->platformTimestamp();
$config['last_push_count'] = count($differences);
$configs[$supplier] = $config;
$this->persist($configs);
return ['messages' => count($chunks), 'rows' => count($differences)];
}
/**
* @param list<array<string,mixed>> $returns
* @return array{messages:int,rows:int}
*/
public function pushReturns(string $supplier, array $returns, string $month, int $companyId): array
{
if ($returns === []) {
throw ValidationException::withMessages([
'dingtalk_return_push' => '该厂家在 ' . $month . ' 没有可推送的退货数据。',
]);
}
$configs = $this->configs();
$config = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw ValidationException::withMessages([
'dingtalk_return_push' => '该厂家还没有配置钉钉群机器人 Webhook。',
]);
}
$this->assertWebhook($webhook);
$summary = $this->summarizeReturns($returns);
$title = $supplier . ' · 供应商退货对账 · ' . $month;
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$title,
$this->returnMessage(
$supplier,
$summary,
$month,
$this->returnShareLinks->pageUrl($companyId, $supplier, $month),
),
);
$config['last_pushed_at'] = $this->platformTimestamp();
$config['last_push_count'] = count($returns);
$configs[$supplier] = $config;
$this->persist($configs);
return ['messages' => 1, 'rows' => count($returns)];
}
public function sendReturnConfirmationReceipt(
string $supplier,
string $month,
int $confirmedRows,
int $disputedRows,
): bool {
$configs = $this->configs();
$config = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
return false;
}
$this->assertWebhook($webhook);
$lines = [
'### 供应商退货确认回执 · ' . $supplier,
'- 对账月份:**' . $month . '**',
'- 本次确认:**' . max(0, $confirmedRows) . '** 行退货扣减',
'- 异议保留:**' . max(0, $disputedRows) . '** 行',
'- 确认方式:厂家通过退货明细链接确认',
'- 确认时间:' . $this->platformTimestamp(),
'- 系统状态:**已回传财务系统**',
];
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$supplier . ' · 退货确认回执 · ' . $month,
implode("\n", $lines),
);
return true;
}
private function platformTimestamp(): string
{
$timezone = trim((string) config('app.platform_timezone', ''));
return now($timezone !== '' ? $timezone : 'Asia/Shanghai')->format('Y-m-d H:i:s');
}
/**
* @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
* @return array{count:int,qty:float,amount:float,pending:int,confirmed:int,disputed:int}
*/
public function summarizeReturns(array $rows): array
{
$summary = [
'count' => count($rows),
'qty' => 0.0,
'amount' => 0.0,
'pending' => 0,
'confirmed' => 0,
'disputed' => 0,
];
foreach ($rows as $row) {
$summary['qty'] += (float) ($row['_supplier_qty'] ?? $row['qty'] ?? 0);
$summary['amount'] += (float) ($row['_supplier_amount'] ?? $row['amount'] ?? 0);
$status = (string) ($row['_status'] ?? '');
if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) {
$summary['confirmed']++;
} elseif ($status === SupplierReturnConfirmation::STATUS_DISPUTED) {
$summary['disputed']++;
} else {
$summary['pending']++;
}
}
$summary['qty'] = round($summary['qty'], 2);
$summary['amount'] = round($summary['amount'], 2);
return $summary;
}
/**
* @param array{count:int,qty:float,amount:float,pending:int,confirmed:int,disputed:int} $summary
*/
private function returnMessage(
string $supplier,
array $summary,
string $month,
string $detailUrl,
): string {
$lines = [
'### 供应商退货对账通知 · ' . $supplier,
'- 业务类型:货款对账 / 退货明细',
'- 对账月份:**' . $month . '**',
'- 本月全部退货:**' . $summary['count'] . '** 行(待确认 ' . $summary['pending']
. ' / 已确认 ' . $summary['confirmed'] . ' / 异议中 ' . $summary['disputed'] . ')',
'- 厂家净扣减:**' . $this->number($summary['qty']) . '** 件,**¥'
. number_format($summary['amount'], 2) . '**',
'',
'#### [查看退货明细表格](' . $detailUrl . ')',
'- 打开后可查看完整表格,并下载 Excel 或 CSV。',
'- 核对无误可在页面点击“全部确认无异议”,确认结果会实时回传财务系统,并向本群发送确认回执。',
'- 明细包含发货时间、条码、商品、数量、单价、金额、仓库、配货单号、快递单号和确认状态。',
'',
'> 链接仅可查看本厂家 ' . $month . ' 的退货数据,有效期 '
. SupplierReturnShareLink::TTL_DAYS . ' 天,无需厂家登录。',
];
return implode("\n", $lines);
}
/**
* @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'), '.');
}
}