diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php index c8af3f3f..f4f4e14e 100644 --- a/app/Http/Controllers/Business/SupplierReconciliation.php +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -6,13 +6,16 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier; use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter; use Modules\EcommerceFinance\Services\SupplierReturnConfirmation; +use Modules\EcommerceFinance\Services\SupplierStatementMatcher; use Modules\EcommerceFinance\Services\SupplierStatementStore; use Modules\EcommerceFinance\Services\SupplierStatementTemplate; use Modules\EcommerceFinance\View\SupplierReconciliationPageData; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; /** * 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。 @@ -25,6 +28,7 @@ class SupplierReconciliation extends Controller private readonly SupplierReturnConfirmation $returnConfirmations, private readonly SupplierReconciliationPageData $pageData, private readonly SupplierReturnCsvExporter $returnCsvExporter, + private readonly SupplierDingTalkNotifier $dingTalkNotifier, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -101,6 +105,74 @@ class SupplierReconciliation extends Controller return $this->returnCsvExporter->download($rows, $supplier); } + public function saveDingTalkRobot(Request $request): RedirectResponse + { + $request->validate([ + 'supplier' => ['required', 'string', 'max:191'], + 'dingtalk_webhook' => ['nullable', 'string', 'max:2048'], + 'dingtalk_secret' => ['nullable', 'string', 'max:256'], + 'dingtalk_at_all' => ['nullable', 'boolean'], + ]); + [$supplier] = $this->returnTarget($request); + $this->dingTalkNotifier->save( + $supplier, + (string) $request->input('dingtalk_webhook', ''), + (string) $request->input('dingtalk_secret', ''), + $request->boolean('dingtalk_at_all'), + ); + + return back()->with('success', "{$supplier} 的钉钉群机器人已保存。"); + } + + public function pushDifferences(Request $request): RedirectResponse + { + $request->validate([ + 'supplier' => ['required', 'string', 'max:191'], + 'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'], + 'reconciler' => ['nullable', 'string', 'max:191'], + 'scope' => ['required', 'in:all,row'], + 'barcode' => ['nullable', 'string', 'max:191'], + 'difference_type' => ['nullable', 'string', 'max:32'], + 'unit_price' => ['nullable', 'numeric'], + ]); + [$supplier, $companyId] = $this->returnTarget($request); + $detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all()); + $differences = array_values(array_filter( + $detail['rows'], + static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH, + )); + + if ($request->input('scope') === 'row') { + $barcode = trim((string) $request->input('barcode')); + $type = trim((string) $request->input('difference_type')); + $unitPrice = (float) $request->input('unit_price'); + $differences = array_values(array_filter( + $differences, + static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode + && (string) ($row['type'] ?? '') === $type + && abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005, + )); + } + + if ($differences === []) { + return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']); + } + + try { + $result = $this->dingTalkNotifier->push($supplier, $differences, [ + 'month' => (string) $request->input('month', ''), + 'reconciler' => (string) $request->input('reconciler', ''), + ]); + } catch (Throwable $exception) { + return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]); + } + + return back()->with( + 'success', + "已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。", + ); + } + private function markReturn(Request $request, string $status, string $message): RedirectResponse { $request->validate(['key' => ['required', 'string', 'max:191']]); diff --git a/modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php b/modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php new file mode 100644 index 00000000..92355058 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php @@ -0,0 +1,323 @@ +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> $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'] = 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> $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 + * @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); + $barcode = trim((string) ($row['barcode'] ?? '')); + $supplierBarcode = trim((string) ($row['supplierBarcode'] ?? '')); + + $lines[] = sprintf( + '%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s', + $rowOffset + $index + 1, + (string) ($row['type'] ?? '差异'), + $barcode, + $supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '', + number_format((float) ($row['unitPrice'] ?? 0), 2), + $this->number($mineQty), + number_format($mineAmount, 2), + $this->number($theirsQty), + number_format($theirsAmount, 2), + (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'), '.'); + } +} diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php index b24c6e5e..33c42f08 100644 --- a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\View; use App\Http\Controllers\Business\PayablesFinalActions; +use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier; use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; use Modules\EcommerceFinance\Services\SupplierReturnConfirmation; use Modules\EcommerceFinance\Services\SupplierStatementMatcher; @@ -23,6 +24,7 @@ class SupplierReconciliationPageData private readonly SupplierStatementStore $statements, private readonly SupplierStatementMatcher $matcher, private readonly SupplierReturnConfirmation $returnConfirmations, + private readonly SupplierDingTalkNotifier $dingTalkNotifier, ) { } @@ -111,6 +113,7 @@ class SupplierReconciliationPageData 'reconciler' => $reconciler, 'selectedSupplier' => $selected, 'detail' => $detail, + 'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null, 'listUrl' => route('business.pages.show', array_filter([ 'section' => 'supplier-reconciliation', 'page' => 'finance-data', @@ -196,6 +199,38 @@ class SupplierReconciliationPageData ]; } + /** + * 按页面当前月份/对账人筛选重新计算财务端比对结果,供差异推送使用。 + * + * @param array $input + * @return array + */ + public function financeDetailForFilters(string $supplier, int $companyId, array $input): array + { + $mine = $this->store->listRows( + PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation', + $companyId, + ); + + $month = $this->filterText($input['month'] ?? ''); + if ($month !== '' && preg_match('/^\d{4}-\d{2}$/', $month) === 1) { + $mine = array_values(array_filter( + $mine, + static fn (array $row): bool => substr(trim((string) ($row['summary_month'] ?? '')), 0, 7) === $month, + )); + } + + $reconciler = $this->filterText($input['reconciler'] ?? ''); + if ($reconciler !== '') { + $mine = array_values(array_filter( + $mine, + static fn (array $row): bool => trim((string) ($row['reconciler'] ?? '')) === $reconciler, + )); + } + + return $this->detail($mine, $supplier, $companyId); + } + /** * @param array $input * @return array{returnPanel:array,returnWarehouses:list} diff --git a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php index 9486cb58..d9e19dd9 100644 --- a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -8,9 +8,13 @@ .supplier-reconciliation-page .sr-field { display: grid; gap: 4px; } .supplier-reconciliation-page .sr-field span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; } .supplier-reconciliation-page select, - .supplier-reconciliation-page input[type="search"] { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; } + .supplier-reconciliation-page input[type="search"], + .supplier-reconciliation-page input[type="url"], + .supplier-reconciliation-page input[type="password"] { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; } .supplier-reconciliation-page .sr-button { display: inline-flex; height: 36px; align-items: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; } .supplier-reconciliation-page .sr-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; } + .supplier-reconciliation-page .sr-button.is-disabled { opacity: .55; cursor: not-allowed; } + .supplier-reconciliation-page .sr-button.is-small { height: 26px; padding: 0 9px; font-size: .75rem; } .supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; } .supplier-reconciliation-page .sr-scroll { min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; } .supplier-reconciliation-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; } @@ -29,6 +33,15 @@ .supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; } .supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; } .supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; } + .supplier-reconciliation-page .sr-robot-config { padding: 10px 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; } + .supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; } + .supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; } + .supplier-reconciliation-page .sr-robot-check { display: flex; align-items: center; gap: 6px; min-height: 36px; color: var(--vip-content-muted, #595959); font-size: .75rem; } + .supplier-reconciliation-page .sr-robot-form input[type="url"], + .supplier-reconciliation-page .sr-robot-form input[type="password"] { width: 100%; min-width: 0; } + @media (max-width: 900px) { + .supplier-reconciliation-page .sr-robot-form { grid-template-columns: minmax(0, 1fr); } + } .supplier-reconciliation-page .sr-empty { padding: 28px; border: 1px dashed var(--vip-border, #D8D2C7); border-radius: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; text-align: center; } @@ -38,6 +51,11 @@ @if ($errors->has('import')) @endif + @if ($errors->has('dingtalk_push') || $errors->has('dingtalk_webhook') || $errors->has('dingtalk_secret')) + + @endif
@@ -84,6 +102,11 @@
@if ($detail) + @php + $differenceCount = (int) $detail['summary']['supplierExtra'] + + (int) $detail['summary']['mismatch'] + + (int) $detail['summary']['supplierMissing']; + @endphp

{{ $selectedSupplier }} · 比对明细

@@ -99,7 +122,65 @@ ← 返回供应商列表 下载对账单模板 + @if ($differenceCount > 0) + + @csrf + + + + + + + @endif
+
+ + 厂家钉钉群机器人: + {{ $robot['configured'] ? '已配置' : '未配置,配置后才能推送差异' }} + @if ($robot['last_pushed_at'] !== '') + · 上次推送 {{ $robot['last_pushed_at'] }}({{ $robot['last_push_count'] }} 条) + @endif + +
+ @csrf + + + +
+ + +
+
+

+ 在厂家钉钉群的「群设置 → 智能群助手 → 添加机器人 → 自定义」中复制 Webhook;如机器人启用加签,请同时填写加签密钥。 + 如机器人使用关键词校验,请把关键词设为「货款对账」。 + Webhook 和密钥保存后仅用于本系统推送,页面不会再次展示完整内容。 +

+
@else
@@ -159,6 +240,7 @@ 厂家金额 结算口径 结算金额 + 推送 @@ -186,9 +268,33 @@ {{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }} {{ $row['note'] }} {{ number_format($row['settleAmount'], 2) }} + + @if ($row['type'] !== '一致') +
+ @csrf + + + + + + + + +
+ @else + — + @endif + @empty -
该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。
+
该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。
@endforelse diff --git a/routes/admin.php b/routes/admin.php index 288db41e..3ef35907 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -412,6 +412,14 @@ Route::post('workspace/supplier-reconciliation/returns/confirm-all', [SupplierRe ->middleware('business.page:supplier-reconciliation,supplier-data') ->name('business.supplier-reconciliation.return.confirm-all'); +Route::post('workspace/supplier-reconciliation/dingtalk-robot', [SupplierReconciliationActions::class, 'saveDingTalkRobot']) + ->middleware('business.page:supplier-reconciliation,finance-data') + ->name('business.supplier-reconciliation.dingtalk-robot.save'); + +Route::post('workspace/supplier-reconciliation/push-differences', [SupplierReconciliationActions::class, 'pushDifferences']) + ->middleware('business.page:supplier-reconciliation,finance-data') + ->name('business.supplier-reconciliation.push-differences'); + Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds') ->middleware('business.page:refunds,non-jingxi-refund-data') ->name('business.refunds.non-jingxi-refund-data.export'); diff --git a/tests/Feature/Business/SupplierReconciliationDingTalkTest.php b/tests/Feature/Business/SupplierReconciliationDingTalkTest.php new file mode 100644 index 00000000..9156bfb1 --- /dev/null +++ b/tests/Feature/Business/SupplierReconciliationDingTalkTest.php @@ -0,0 +1,245 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + public function test_finance_detail_shows_push_buttons_and_robot_configuration(): void + { + $this->seedReconciliation(); + + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSeeText('推送全部差异(3 条)') + ->assertSeeText('推送本条') + ->assertSeeText('厂家钉钉群机器人') + ->assertSeeText('未配置,配置后才能推送差异') + ->assertDontSee(self::WEBHOOK); + } + + public function test_robot_configuration_is_saved_and_secret_is_not_rendered(): void + { + $this->seedReconciliation(); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.dingtalk-robot.save'), [ + 'supplier' => '晨丰', + 'dingtalk_webhook' => self::WEBHOOK, + 'dingtalk_secret' => 'SEC-test-123', + 'dingtalk_at_all' => '1', + ]) + ->assertStatus(302) + ->assertSessionHas('success', '晨丰 的钉钉群机器人已保存。'); + + $config = app(SupplierDingTalkNotifier::class)->configuration('晨丰'); + $this->assertTrue($config['configured']); + $this->assertTrue($config['at_all']); + $this->assertStringContainsString('access_token=••••••', $config['webhook_hint']); + $this->assertStringNotContainsString('supplier-test-token', (string) setting('supplier_reconciliation.dingtalk_robots')); + $this->assertStringNotContainsString('SEC-test-123', (string) setting('supplier_reconciliation.dingtalk_robots')); + + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSeeText('已配置') + ->assertDontSeeText('supplier-test-token') + ->assertDontSeeText('SEC-test-123'); + } + + public function test_invalid_robot_webhook_is_rejected(): void + { + $this->withExceptionHandling(); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.dingtalk-robot.save'), [ + 'supplier' => '晨丰', + 'dingtalk_webhook' => 'https://example.com/robot/send?access_token=unsafe', + ]) + ->assertStatus(302) + ->assertSessionHasErrors('dingtalk_webhook'); + } + + public function test_push_all_differences_sends_only_current_supplier_and_filtered_rows(): void + { + $this->seedReconciliation(); + $this->saveRobot(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.push-differences'), [ + 'supplier' => '晨丰', + 'month' => '2026-09', + 'reconciler' => '张三', + 'scope' => 'all', + ]) + ->assertStatus(302) + ->assertSessionHas('success', '已推送 3 条差异到 晨丰 钉钉群(1 条消息)。'); + + Http::assertSentCount(1); + Http::assertSent(function ($request): bool { + $text = (string) ($request['markdown']['text'] ?? ''); + + return str_contains($request->url(), 'oapi.dingtalk.com/robot/send') + && str_contains($request->url(), 'timestamp=') + && str_contains($request->url(), 'sign=') + && str_contains($text, '货款对账差异通知 · 晨丰') + && str_contains($text, 'B-1') + && str_contains($text, 'C-1') + && str_contains($text, 'D-1') + && ! str_contains($text, '`A-1`') + && ! str_contains($text, '鸣飞家具'); + }); + + $config = app(SupplierDingTalkNotifier::class)->configuration('晨丰'); + $this->assertNotSame('', $config['last_pushed_at']); + $this->assertSame(3, $config['last_push_count']); + } + + public function test_push_single_difference_only_sends_the_selected_row(): void + { + $this->seedReconciliation(); + $this->saveRobot(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.push-differences'), [ + 'supplier' => '晨丰', + 'month' => '2026-09', + 'reconciler' => '张三', + 'scope' => 'row', + 'barcode' => 'B-1', + 'difference_type' => '金额不一致', + 'unit_price' => '50', + ]) + ->assertStatus(302) + ->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。'); + + Http::assertSent(function ($request): bool { + $text = (string) ($request['markdown']['text'] ?? ''); + + return str_contains($text, 'B-1') + && ! str_contains($text, '`C-1`') + && ! str_contains($text, '`D-1`'); + }); + } + + public function test_push_without_robot_configuration_is_rejected_without_http_call(): void + { + $this->seedReconciliation(); + Http::fake(); + + $this->loginAs() + ->post(route('business.supplier-reconciliation.push-differences'), [ + 'supplier' => '晨丰', + 'month' => '2026-09', + 'reconciler' => '张三', + 'scope' => 'all', + ]) + ->assertStatus(302) + ->assertSessionHasErrors('dingtalk_push'); + + Http::assertNothingSent(); + } + + public function test_large_difference_set_is_sent_in_multiple_robot_messages(): void + { + $this->saveRobot(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + $rows = []; + for ($index = 1; $index <= 31; $index++) { + $rows[] = [ + 'barcode' => sprintf('BULK-%02d', $index), + 'supplierBarcode' => '', + 'unitPrice' => 10, + 'mineQty' => 2, + 'theirsQty' => 1, + 'mineAmount' => 20, + 'theirsAmount' => 10, + 'type' => '金额不一致', + 'settleAmount' => 10, + 'note' => '金额/数量不一致:按金额低的一方结算', + ]; + } + + $result = app(SupplierDingTalkNotifier::class)->push('晨丰', $rows, [ + 'month' => '2026-09', + 'reconciler' => '张三', + ]); + + $this->assertSame(31, $result['rows']); + $this->assertSame(2, $result['messages']); + Http::assertSentCount(2); + } + + private function seedReconciliation(): void + { + $companyId = (int) $this->company->id; + $store = app(SupplierPayableOverrideStore::class); + foreach ([ + 'mine:1' => ['skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200'], + 'mine:2' => ['skuCode' => 'B-1', 'qty' => '2', 'unitPrice' => '50', 'amount' => '100'], + 'mine:3' => ['skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30'], + ] as $key => $row) { + $store->save('final:supplier:payment-reconciliation', $key, $row + [ + 'summary_month' => '2026-09', + 'supplier' => '晨丰', + 'reconciler' => '张三', + ], $companyId, null); + } + + app(SupplierStatementStore::class)->save('晨丰', [ + $this->statementRow('A-1', '100', '2', '200'), + $this->statementRow('B-1', '50', '1', '50'), + $this->statementRow('D-1', '80', '1', '80'), + ], 'statement.csv', $companyId, null); + } + + private function saveRobot(): void + { + app(SupplierDingTalkNotifier::class)->save('晨丰', self::WEBHOOK, 'SEC-test-123', false); + } + + private function pageUrl(): string + { + return route('business.pages.show', [ + 'section' => 'supplier-reconciliation', + 'page' => 'finance-data', + 'supplier' => '晨丰', + 'month' => '2026-09', + 'reconciler' => '张三', + ]); + } + + private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array + { + return [ + 'expressName' => null, + 'expressNo' => null, + 'purchaseNo' => '编号-' . $barcode, + 'createdAt' => '2026-09-30', + 'supplier' => '晨丰', + 'barcode' => $barcode, + 'skuName' => '规格甲', + 'qty' => $qty, + 'unitPrice' => $unitPrice, + 'amount' => $amount, + ]; + } +}