回执和机器人最近推送时间改用平台时区,避免 UTC 导致北京时间少 8 小时。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
511 lines
20 KiB
PHP
511 lines
20 KiB
PHP
<?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'), '.');
|
||
}
|
||
}
|