每日自动推送未处理供应商,增加处理截止时间和截图上传。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
799 lines
30 KiB
PHP
799 lines
30 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* 供应商负数预警:向财务采购群机器人发送任务,并接收采购按供应商填写的处理结果。
|
|
*/
|
|
class SupplierNegativeAlertDingTalkWorkflow
|
|
{
|
|
public const SETTING_KEY = 'supplier_negative_alerts.dingtalk_group';
|
|
|
|
public const DEFAULT_GROUP_NAME = '财务&采购沟通群';
|
|
|
|
private const TOKEN_TTL_DAYS = 30;
|
|
|
|
/** @return array{configured:bool,webhook_hint:string,group_name:string} */
|
|
public function configuration(): array
|
|
{
|
|
$config = $this->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<array<string,mixed>> $rows 已按采购筛选的负数预警记录
|
|
* @param array<string,mixed> $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<array<string,mixed>> $rows
|
|
* @param array<string,mixed> $filters
|
|
* @return array{
|
|
* purchaser_count:int,
|
|
* supplier_count:int,
|
|
* row_count:int,
|
|
* unassigned_supplier_count:int,
|
|
* group_name:string,
|
|
* task_urls:array<string,string>
|
|
* }
|
|
*/
|
|
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<string> $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<string> $suppliers
|
|
* @return array<string,object>
|
|
*/
|
|
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<array<string,mixed>> $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<string> */
|
|
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<string,list<object>> */
|
|
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<array<string,mixed>> $snapshot
|
|
* @param array<int,array<int,UploadedFile>> $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<string,string> */
|
|
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<string,mixed> */
|
|
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<array<string,mixed>> $rows
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
}
|