config(); $webhook = trim((string) ($config['webhook'] ?? '')); return [ 'configured' => $webhook !== '', 'webhook_hint' => $this->maskWebhook($webhook), 'group_name' => trim((string) ($config['group_name'] ?? self::DEFAULT_GROUP_NAME)), ]; } public function saveConfiguration(string $webhook, string $secret, string $groupName): void { $config = $this->config(); $existingWebhook = trim((string) ($config['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) ($config['secret'] ?? '')); } if (mb_strlen($secret) > 256) { throw ValidationException::withMessages(['dingtalk_secret' => '机器人加签密钥过长。']); } $groupName = trim($groupName); if ($groupName === '') { $groupName = self::DEFAULT_GROUP_NAME; } if (mb_strlen($groupName) > 191) { throw ValidationException::withMessages(['dingtalk_group_name' => '钉钉群名称不能超过 191 个字符。']); } setting()->set(self::SETTING_KEY, json_encode([ 'webhook' => Crypt::encryptString($webhook), 'secret' => $secret !== '' ? Crypt::encryptString($secret) : '', 'group_name' => $groupName, ], JSON_UNESCAPED_UNICODE)); setting()->save(); } /** * @param list> $rows 已按采购筛选的负数预警记录 * @param array $filters * @return array{assignment_id:int,supplier_count:int,row_count:int,group_name:string,task_url:string} */ public function send( array $rows, string $purchaser, array $filters, int $companyId, ?int $userId, ): array { $purchaser = trim($purchaser); $config = $this->config(); $webhook = trim((string) ($config['webhook'] ?? '')); if ($webhook === '') { throw ValidationException::withMessages([ 'dingtalk_send' => '财务&采购沟通群的机器人尚未配置。', ]); } $this->assertWebhook($webhook); if ($purchaser === '') { throw ValidationException::withMessages(['dingtalk_send' => '请先选择采购。']); } $supplierGroups = []; foreach ($rows as $row) { $supplier = trim((string) ($row['supplier'] ?? '')); if ($supplier === '') { continue; } if (trim((string) ($row['purchaser'] ?? '')) !== $purchaser) { continue; } $supplierGroups[$supplier][] = [ 'summary_month' => trim((string) ($row['summary_month'] ?? '')), 'checker' => trim((string) ($row['checker'] ?? '')), 'settlement_method' => trim((string) ($row['settlement_method'] ?? '')), 'negative_reason' => trim((string) ($row['negative_reason'] ?? '')), 'alert_amount' => round((float) ($row['_negative_alert_amount'] ?? 0), 2), ]; } if ($supplierGroups === []) { throw ValidationException::withMessages([ 'dingtalk_send' => '当前筛选没有可发送给该采购的供应商。', ]); } $snapshot = []; foreach ($supplierGroups as $supplier => $supplierRows) { $months = array_values(array_unique(array_filter(array_column($supplierRows, 'summary_month')))); rsort($months); $reasons = array_values(array_unique(array_filter(array_column($supplierRows, 'negative_reason')))); $snapshot[] = [ 'supplier' => $supplier, 'row_count' => count($supplierRows), 'months' => $months, 'negative_reasons' => $reasons, 'alert_amount' => round(array_sum(array_column($supplierRows, 'alert_amount')), 2), 'rows' => $supplierRows, ]; } $token = Str::random(64); $groupName = trim((string) ($config['group_name'] ?? self::DEFAULT_GROUP_NAME)); $now = now(); $assignmentId = DB::table('supplier_negative_alert_assignments')->insertGetId([ 'company_id' => $companyId, 'purchaser_name' => $purchaser, 'group_name' => $groupName, 'filters' => json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'supplier_count' => count($snapshot), 'row_count' => count($rows), 'token_hash' => hash('sha256', $token), 'token_expires_at' => $now->copy()->addDays(self::TOKEN_TTL_DAYS), 'status' => 'sending', 'created_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]); $taskUrl = route('supplier-negative-alert.task', ['token' => $token]); $month = trim((string) ($filters['month'] ?? '')); $title = '供应商负数预警 · ' . $purchaser; $text = implode("\n", [ '### 供应商负数预警待处理', '- 采购:**' . $purchaser . '**', '- 月份:' . ($month !== '' ? $month : '全部月份'), '- 供应商:**' . count($snapshot) . '** 家', '- 预警记录:**' . count($rows) . '** 条', '', '请在钉钉内点击下方按钮,按每个供应商分别填写处理结果。', ]); try { $this->sendActionCard( $webhook, trim((string) ($config['secret'] ?? '')), $title, $text, $taskUrl, ); } catch (Throwable $exception) { DB::table('supplier_negative_alert_assignments')->where('id', $assignmentId)->update([ 'status' => 'failed', 'error_message' => mb_substr($exception->getMessage(), 0, 500), 'updated_at' => now(), ]); throw $exception; } $sentAt = now(); DB::table('supplier_negative_alert_assignments')->where('id', $assignmentId)->update([ 'status' => 'sent', 'sent_at' => $sentAt, 'error_message' => null, 'updated_at' => $sentAt, ]); return [ 'assignment_id' => $assignmentId, 'supplier_count' => count($snapshot), 'row_count' => count($rows), 'group_name' => $groupName, 'task_url' => $taskUrl, ]; } /** * 一次点击按采购分组,生成每人独立任务,并合成一条群消息发送。 * * @param list> $rows * @param array $filters * @return array{ * purchaser_count:int, * supplier_count:int, * row_count:int, * unassigned_supplier_count:int, * group_name:string, * task_urls:array * } */ public function sendGrouped( array $rows, array $filters, int $companyId, ?int $userId, ): array { $config = $this->config(); $webhook = trim((string) ($config['webhook'] ?? '')); if ($webhook === '') { throw ValidationException::withMessages([ 'dingtalk_send' => '财务&采购沟通群的机器人尚未配置。', ]); } $this->assertWebhook($webhook); $purchaserGroups = []; $unassignedSuppliers = []; $unassignedRows = 0; foreach ($rows as $row) { $supplier = trim((string) ($row['supplier'] ?? '')); if ($supplier === '') { continue; } $purchaser = trim((string) ($row['purchaser'] ?? '')); if ($purchaser === '') { $unassignedSuppliers[$supplier] = true; $unassignedRows++; continue; } $purchaserGroups[$purchaser][] = $row; } if ($purchaserGroups === []) { throw ValidationException::withMessages([ 'dingtalk_send' => '当前筛选没有可发送给采购的供应商。', ]); } $groupName = trim((string) ($config['group_name'] ?? self::DEFAULT_GROUP_NAME)); $assignments = []; $supplierCount = 0; $rowCount = 0; foreach ($purchaserGroups as $purchaser => $purchaserRows) { $snapshot = $this->snapshotForRows($purchaserRows); if ($snapshot === []) { continue; } $token = Str::random(64); $now = now(); $assignmentId = DB::table('supplier_negative_alert_assignments')->insertGetId([ 'company_id' => $companyId, 'purchaser_name' => $purchaser, 'group_name' => $groupName, 'filters' => json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'supplier_count' => count($snapshot), 'row_count' => count($purchaserRows), 'token_hash' => hash('sha256', $token), 'token_expires_at' => $now->copy()->addDays(self::TOKEN_TTL_DAYS), 'status' => 'sending', 'created_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]); $assignments[] = [ 'id' => $assignmentId, 'purchaser' => $purchaser, 'supplier_count' => count($snapshot), 'row_count' => count($purchaserRows), 'task_url' => route('supplier-negative-alert.task', ['token' => $token]), ]; $supplierCount += count($snapshot); $rowCount += count($purchaserRows); } if ($assignments === []) { throw ValidationException::withMessages([ 'dingtalk_send' => '当前筛选没有可发送给采购的供应商。', ]); } $month = trim((string) ($filters['month'] ?? '')); $lines = [ '### 供应商负数预警待处理', '- 月份:' . ($month !== '' ? $month : '全部月份'), '- 采购:**' . count($assignments) . '** 位', '- 供应商:**' . $supplierCount . '** 家', '- 预警记录:**' . $rowCount . '** 条', ]; if ($unassignedSuppliers !== []) { $lines[] = '- 未分配采购:**' . count($unassignedSuppliers) . '** 家供应商(' . $unassignedRows . ' 条预警,未发送)'; } $lines[] = ''; $lines[] = '请对应采购点击自己的链接,按每家供应商分别填写处理结果:'; foreach ($assignments as $assignment) { $lines[] = '- [' . $assignment['purchaser'] . ' · ' . $assignment['supplier_count'] . ' 家供应商](' . $assignment['task_url'] . ')'; } $assignmentIds = array_column($assignments, 'id'); try { $this->sendMarkdown( $webhook, trim((string) ($config['secret'] ?? '')), '供应商负数预警 · 按采购分派', implode("\n", $lines), ); } catch (Throwable $exception) { DB::table('supplier_negative_alert_assignments')->whereIn('id', $assignmentIds)->update([ 'status' => 'failed', 'error_message' => mb_substr($exception->getMessage(), 0, 500), 'updated_at' => now(), ]); throw $exception; } $sentAt = now(); DB::table('supplier_negative_alert_assignments')->whereIn('id', $assignmentIds)->update([ 'status' => 'sent', 'sent_at' => $sentAt, 'error_message' => null, 'updated_at' => $sentAt, ]); return [ 'purchaser_count' => count($assignments), 'supplier_count' => $supplierCount, 'row_count' => $rowCount, 'unassigned_supplier_count' => count($unassignedSuppliers), 'group_name' => $groupName, 'task_urls' => array_column($assignments, 'task_url', 'purchaser'), ]; } public function findByToken(string $token): ?object { if ($token === '') { return null; } return DB::table('supplier_negative_alert_assignments') ->where('token_hash', hash('sha256', $token)) ->where('token_expires_at', '>=', now()) ->whereIn('status', ['sent', 'processing', 'completed']) ->first(); } /** @param list $results */ public function submit(string $token, array $results): object { $assignment = $this->findByToken($token); if ($assignment === null) { throw ValidationException::withMessages(['results' => '任务链接无效或已过期,请财务重新发送。']); } $snapshot = json_decode((string) $assignment->snapshot, true); $snapshot = is_array($snapshot) ? array_values($snapshot) : []; if ($snapshot === [] || count($results) !== count($snapshot)) { throw ValidationException::withMessages(['results' => '请为每个供应商填写处理结果。']); } $period = $this->latestPeriodSummary($snapshot)['month']; $submittedCount = 0; $now = now(); DB::transaction(function () use ($assignment, $snapshot, $results, $period, $now, &$submittedCount): void { foreach ($snapshot as $index => $supplier) { $supplierName = trim((string) ($supplier['supplier'] ?? '')); $resultText = trim((string) ($results[$index] ?? '')); if ($supplierName === '' || $resultText === '') { continue; } DB::table('supplier_negative_alert_results')->updateOrInsert( [ 'assignment_id' => (int) $assignment->id, 'supplier_name' => $supplierName, ], [ 'company_id' => (int) $assignment->company_id, 'purchaser_name' => (string) $assignment->purchaser_name, 'period' => $period !== '' ? $period : null, 'result_text' => $resultText, 'submitted_at' => $now, 'created_at' => $now, 'updated_at' => $now, ], ); $submittedCount++; } if ($submittedCount === 0) { throw ValidationException::withMessages([ 'results' => '请至少填写一个供应商的处理结果。', ]); } $handledCount = DB::table('supplier_negative_alert_results') ->where('assignment_id', (int) $assignment->id) ->where('result_text', '!=', '') ->count(); $completed = $handledCount >= count($snapshot); DB::table('supplier_negative_alert_assignments')->where('id', $assignment->id)->update([ 'status' => $completed ? 'completed' : 'processing', 'completed_at' => $completed ? $now : null, 'updated_at' => $now, ]); }); return DB::table('supplier_negative_alert_assignments')->where('id', $assignment->id)->first(); } /** * @param list $suppliers * @return array */ public function latestResults(array $suppliers, int $companyId): array { $suppliers = array_values(array_unique(array_filter(array_map('trim', $suppliers)))); if ($suppliers === []) { return []; } $rows = DB::table('supplier_negative_alert_results') ->where('company_id', $companyId) ->whereIn('supplier_name', $suppliers) ->orderByDesc('submitted_at') ->orderByDesc('id') ->get(['supplier_name', 'purchaser_name', 'result_text', 'submitted_at']); $latest = []; foreach ($rows as $row) { $supplier = trim((string) $row->supplier_name); if ($supplier !== '' && ! isset($latest[$supplier])) { $latest[$supplier] = $row; } } return $latest; } /** * 任务页展示:按任务快照中的最新账期汇总该采购的负数金额。 * * @param list> $snapshot * @return array{month:string,negative_amount:float,supplier_count:int,row_count:int} */ public function latestPeriodSummary(array $snapshot): array { $latestMonth = ''; foreach ($snapshot as $supplier) { foreach ((array) ($supplier['rows'] ?? []) as $row) { $month = trim((string) ($row['summary_month'] ?? '')); if (preg_match('/^\d{4}-\d{2}$/', $month) === 1 && $month > $latestMonth) { $latestMonth = $month; } } } $negativeAmount = 0.0; $supplierNames = []; $rowCount = 0; foreach ($snapshot as $supplier) { $supplierName = trim((string) ($supplier['supplier'] ?? '')); foreach ((array) ($supplier['rows'] ?? []) as $row) { $month = trim((string) ($row['summary_month'] ?? '')); if ($latestMonth !== '' && $month !== $latestMonth) { continue; } $amount = (float) ($row['alert_amount'] ?? 0); if ($amount >= 0) { continue; } $negativeAmount += $amount; $rowCount++; if ($supplierName !== '') { $supplierNames[$supplierName] = true; } } } return [ 'month' => $latestMonth, 'negative_amount' => round($negativeAmount, 2), 'supplier_count' => count($supplierNames), 'row_count' => $rowCount, ]; } public function processingDeadline(string $period): ?string { if (preg_match('/^\d{4}-\d{2}$/', $period) !== 1) { return null; } return CarbonImmutable::createFromFormat( '!Y-m-d', $period . '-01', (string) config('app.timezone', 'Asia/Shanghai'), )->addMonthNoOverflow()->endOfMonth()->setTime(18, 0)->format('Y-m-d H:i'); } /** @return list */ public function handledSuppliersForPeriod(int $companyId, string $period): array { if (preg_match('/^\d{4}-\d{2}$/', $period) !== 1) { return []; } return DB::table('supplier_negative_alert_results') ->where('company_id', $companyId) ->where('period', $period) ->where('result_text', '!=', '') ->pluck('supplier_name') ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->unique() ->values() ->all(); } /** @return array> */ public function attachmentsForAssignment(int $assignmentId): array { $attachments = []; foreach (DB::table('supplier_negative_alert_result_attachments') ->where('assignment_id', $assignmentId) ->orderBy('id') ->get() as $attachment) { $attachments[trim((string) $attachment->supplier_name)][] = $attachment; } return $attachments; } /** * @param list> $snapshot * @param array> $uploads */ public function storeAttachments(object $assignment, array $snapshot, array $uploads): void { if ($uploads === []) { return; } $existingCounts = DB::table('supplier_negative_alert_result_attachments') ->where('assignment_id', (int) $assignment->id) ->selectRaw('supplier_name, COUNT(*) AS aggregate') ->groupBy('supplier_name') ->pluck('aggregate', 'supplier_name') ->map(static fn (mixed $count): int => (int) $count) ->all(); $disk = Storage::disk('uploads'); foreach ($snapshot as $index => $supplier) { $supplierName = trim((string) ($supplier['supplier'] ?? '')); $files = array_values(array_filter( (array) ($uploads[$index] ?? []), static fn (mixed $file): bool => $file instanceof UploadedFile, )); if ($supplierName === '' || $files === []) { continue; } $count = (int) ($existingCounts[$supplierName] ?? 0); foreach ($files as $file) { if (++$count > 5) { throw ValidationException::withMessages([ 'screenshots' => $supplierName . ' 最多上传 5 张处理截图。', ]); } $extension = strtolower($file->getClientOriginalExtension()); $filename = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : ''); $path = $disk->putFileAs( 'supplier-negative-alerts/' . (int) $assignment->company_id . '/' . (int) $assignment->id . '/' . hash('sha256', $supplierName), $file, $filename, ); if ($path === false) { throw new RuntimeException('处理截图保存失败。'); } DB::table('supplier_negative_alert_result_attachments')->insert([ 'assignment_id' => (int) $assignment->id, 'company_id' => (int) $assignment->company_id, 'supplier_name' => $supplierName, 'path' => $path, 'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 255), 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'size' => $file->getSize() ?: 0, 'created_at' => now(), 'updated_at' => now(), ]); } } } public function attachmentForAssignment(int $assignmentId, int $attachmentId): ?object { return DB::table('supplier_negative_alert_result_attachments') ->where('assignment_id', $assignmentId) ->where('id', $attachmentId) ->first(); } /** @return array */ public function resultsForAssignment(int $assignmentId): array { return DB::table('supplier_negative_alert_results') ->where('assignment_id', $assignmentId) ->orderBy('id') ->pluck('result_text', 'supplier_name') ->map(static fn (mixed $value): string => trim((string) $value)) ->all(); } /** @return array */ private function config(): array { $value = setting(self::SETTING_KEY, []); if (is_string($value)) { $value = json_decode($value, true) ?: []; } if (! is_array($value)) { return []; } $value['webhook'] = $this->decryptValue($value['webhook'] ?? ''); $value['secret'] = $this->decryptValue($value['secret'] ?? ''); return $value; } private function sendActionCard( string $webhook, string $secret, string $title, string $text, string $taskUrl, ): 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(15)->post($url, [ 'msgtype' => 'actionCard', 'actionCard' => [ 'title' => $title, 'text' => $text, 'btnOrientation' => '0', 'singleTitle' => '填写处理结果', 'singleURL' => $taskUrl, ], 'at' => ['atMobiles' => [], 'isAtAll' => false], ]); 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 sendMarkdown( string $webhook, string $secret, 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(15)->post($url, [ 'msgtype' => 'markdown', 'markdown' => ['title' => $title, 'text' => $text], 'at' => ['atMobiles' => [], 'isAtAll' => false], ]); 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'] ?? ''), ); } } /** * @param list> $rows * @return list> */ private function snapshotForRows(array $rows): array { $supplierGroups = []; foreach ($rows as $row) { $supplier = trim((string) ($row['supplier'] ?? '')); if ($supplier === '') { continue; } $supplierGroups[$supplier][] = [ 'summary_month' => trim((string) ($row['summary_month'] ?? '')), 'checker' => trim((string) ($row['checker'] ?? '')), 'settlement_method' => trim((string) ($row['settlement_method'] ?? '')), 'negative_reason' => trim((string) ($row['negative_reason'] ?? '')), 'alert_amount' => round((float) ($row['_negative_alert_amount'] ?? 0), 2), ]; } $snapshot = []; foreach ($supplierGroups as $supplier => $supplierRows) { $months = array_values(array_unique(array_filter(array_column($supplierRows, 'summary_month')))); rsort($months); $reasons = array_values(array_unique(array_filter(array_column($supplierRows, 'negative_reason')))); $snapshot[] = [ 'supplier' => $supplier, 'row_count' => count($supplierRows), 'months' => $months, 'negative_reasons' => $reasons, 'alert_amount' => round(array_sum(array_column($supplierRows, 'alert_amount')), 2), 'rows' => $supplierRows, ]; } return $snapshot; } 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。', ]); } } private function maskWebhook(string $webhook): string { if ($webhook === '') { return ''; } $host = (string) (parse_url($webhook, PHP_URL_HOST) ?? ''); return $host !== '' ? 'https://' . $host . '/robot/send?access_token=••••••' : '已配置'; } private function decryptValue(mixed $value): string { $value = trim((string) $value); if ($value === '') { return ''; } try { return Crypt::decryptString($value); } catch (Throwable) { return $value; } } }