configurationFrom($this->configs()[$supplier] ?? null); } /** * 一次读取配置并返回多个厂家的推送状态,避免供应商列表逐行查询。 * * @param list $suppliers * @return array */ 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> $differences * @param array $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> $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> $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> $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> $rows * @param array{count:int,extra:int,mismatch:int,missing:int,difference_amount:float} $summary * @param array $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> */ 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> $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'), '.'); } }