CaiWuXiTong/modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php
苏木 801df996d4 feat: 增加供应商整月退货钉钉推送 (#833)
增加供应商对账整月退货推送入口,支持全部确认状态、按消息容量自动分批、不限制退货行数;补充设置字段容量迁移。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-02 22:02:18 +08:00

555 lines
22 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;
/** 钉钉单条内容有大小上限;这里留出 JSON 与签名参数的安全余量。 */
private const RETURN_MESSAGE_MAX_BYTES = 18_000;
/** @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>> $returns
* @return array{messages:int,rows:int}
*/
public function pushReturns(string $supplier, array $returns, string $month): 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);
$chunks = $this->returnMessageChunks($supplier, $returns, $summary, $month);
$total = count($chunks);
$rowOffset = 0;
foreach ($chunks as $index => $chunk) {
$title = $supplier . ' · 供应商退货对账 · ' . $month;
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$title,
$this->returnMessage($supplier, $chunk, $summary, $month, $index + 1, $total, $rowOffset),
);
$rowOffset += count($chunk);
}
$config['last_pushed_at'] = now()->format('Y-m-d H:i:s');
$config['last_push_count'] = count($returns);
$configs[$supplier] = $config;
$this->persist($configs);
return ['messages' => count($chunks), 'rows' => count($returns)];
}
/**
* @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 list<array<string,mixed>> $rows
* @param array{count:int,qty:float,amount:float,pending:int,confirmed:int,disputed:int} $summary
* @return list<list<array<string,mixed>>>
*/
private function returnMessageChunks(string $supplier, array $rows, array $summary, string $month): array
{
$chunks = [];
$current = [];
$rowOffset = 0;
foreach (array_values($rows) as $row) {
$candidate = [...$current, $row];
$messageSize = strlen($this->returnMessage($supplier, $candidate, $summary, $month, 1, 1, $rowOffset));
if ($current !== [] && $messageSize > self::RETURN_MESSAGE_MAX_BYTES) {
$chunks[] = $current;
$rowOffset += count($current);
$current = [$row];
continue;
}
if ($current === [] && $messageSize > self::RETURN_MESSAGE_MAX_BYTES) {
throw ValidationException::withMessages([
'dingtalk_return_push' => '第 ' . ($rowOffset + 1) . ' 条退货内容超过钉钉单条消息容量,无法推送。',
]);
}
$current = $candidate;
}
if ($current !== []) {
$chunks[] = $current;
}
return $chunks;
}
/**
* @param list<array<string,mixed>> $rows
* @param array{count:int,qty:float,amount:float,pending:int,confirmed:int,disputed:int} $summary
*/
private function returnMessage(
string $supplier,
array $rows,
array $summary,
string $month,
int $part,
int $parts,
int $rowOffset,
): string {
$lines = [
'### 供应商退货对账通知 · ' . $supplier,
'- 业务类型:货款对账 / 退货明细',
'- 对账月份:**' . $month . '**',
'- 本月全部退货:**' . $summary['count'] . '** 行(待确认 ' . $summary['pending']
. ' / 已确认 ' . $summary['confirmed'] . ' / 异议中 ' . $summary['disputed'] . ')',
'- 厂家净扣减:**' . $this->number($summary['qty']) . '** 件,**¥'
. number_format($summary['amount'], 2) . '**',
];
if ($parts > 1) {
$lines[] = '- 本条为第 ' . $part . ' / ' . $parts . ' 条。';
}
$lines[] = '';
foreach ($rows as $index => $row) {
$date = $this->messageText($row['send_time'] ?? '', 64);
$barcode = $this->messageText($row['barcode'] ?? '', 128);
$skuName = $this->messageText($row['sku_name'] ?? '', 160);
$status = $this->returnStatusLabel((string) ($row['_status'] ?? ''));
$target = $this->messageText($row['_accounting_label'] ?? '厂家扣减', 40);
$qty = (float) ($row['qty'] ?? $row['_supplier_qty'] ?? 0);
$supplierQty = (float) ($row['_supplier_qty'] ?? $qty);
$unitPrice = (float) ($row['unit_price'] ?? 0);
$amount = (float) ($row['amount'] ?? 0);
$supplierAmount = (float) ($row['_supplier_amount'] ?? $amount);
$lines[] = sprintf(
'%d. **%s**|条码:%s%s',
$rowOffset + $index + 1,
$date !== '' ? $date : '日期缺失',
$barcode !== '' ? $barcode : '—',
$skuName !== '' ? '|' . $skuName : '',
);
$lines[] = ' 状态:' . $status . '|记账归属:' . $target
. '|数量:' . $this->number($qty)
. '|单价:¥' . number_format($unitPrice, 2)
. '|金额:¥' . number_format($amount, 2);
$lines[] = ' 厂家净扣减:' . $this->number($supplierQty)
. ' 件 / ¥' . number_format($supplierAmount, 2);
$detail = [];
foreach ([
'仓库' => [$row['warehouse'] ?? '', 80],
'WMS配货单号' => [$row['wms_pick_no'] ?? '', 100],
'快递单号' => [$row['express_no'] ?? '', 100],
'备注' => [$row['remark'] ?? '', 300],
] as $label => [$value, $maxLength]) {
$value = $this->messageText($value, $maxLength);
if ($value !== '') {
$detail[] = $label . ':' . $value;
}
}
if ($detail !== []) {
$lines[] = ' ' . implode('|', $detail);
}
$accountingNote = $this->messageText($row['_accounting_note'] ?? '', 300);
if ($accountingNote !== '' && $accountingNote !== '按厂家退货扣减') {
$lines[] = ' ' . $accountingNote;
}
}
$lines[] = '';
$lines[] = '> 本条推送包含该厂家整月全部退货记录,不按确认状态筛选。请在本群核对;如有异议,请回复条目序号和原因。';
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 returnStatusLabel(string $status): string
{
return match ($status) {
SupplierReturnConfirmation::STATUS_CONFIRMED => '已确认',
SupplierReturnConfirmation::STATUS_DISPUTED => '异议中',
default => '待确认',
};
}
private function messageText(mixed $value, int $maxLength): string
{
$text = is_scalar($value) ? trim((string) $value) : '';
if ($text === '' || mb_strlen($text) <= $maxLength) {
return $text;
}
return mb_substr($text, 0, max(1, $maxLength - 1)) . '…';
}
private function number(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}