diff --git a/app/Business/StoreViolationAlertCalculator.php b/app/Business/StoreViolationAlertCalculator.php new file mode 100644 index 00000000..f875be7e --- /dev/null +++ b/app/Business/StoreViolationAlertCalculator.php @@ -0,0 +1,273 @@ +>|null */ + private ?array $rules = null; + + /** @var array|null */ + private ?array $ownerMap = null; + + /** @var array */ + private array $ownerFallback = []; + + public function __construct( + private readonly StoreViolationRuleRepository $ruleRepository, + private readonly StoreAccountMirrorRepository $storeAccounts, + ) { + } + + public function flush(): void + { + $this->rules = null; + $this->ownerMap = null; + $this->ownerFallback = []; + } + + /** @return array 预警相关字段(用于同步/导入写入) */ + public function calculate( + string $platform, + ?string $shopName, + ?string $violationType, + ?string $violationDate, + ?string $appealDeadline, + ): array { + $rule = self::matchRule($this->rules(), $platform, $violationType); + $needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true); + $level = null; + if ($needsAlert) { + $level = trim((string) ($rule['alert_level'] ?? '')); + if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) { + $level = 'P2'; + } + } + + $hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null + ? (int) $rule['handling_hours'] + : null; + + return [ + 'needs_alert' => $needsAlert, + 'alert_level' => $level, + 'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null, + 'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null, + 'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null, + 'handling_hours' => $needsAlert ? $hours : null, + 'owner_name' => $this->ownerFor($shopName), + 'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level), + ]; + } + + /** @return list> */ + private function rules(): array + { + if ($this->rules === null) { + try { + $this->rules = $this->ruleRepository->activeRules(); + } catch (Throwable) { + $this->rules = []; + } + } + + return $this->rules; + } + + /** + * 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。 + */ + public static function cleanViolationType(?string $text): string + { + $text = trim((string) $text); + if ($text === '') { + return ''; + } + + $previous = null; + while ($previous !== $text) { + $previous = $text; + $text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text; + } + + return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text; + } + + private static function loose(string $text): string + { + return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text; + } + + /** + * 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。 + * + * @param list> $rules 已按优先级排序 + * @return array|null + */ + public static function matchRule(array $rules, string $platform, ?string $violationType): ?array + { + $record = self::cleanViolationType($violationType); + if ($record === '') { + return null; + } + + $looseRecord = self::loose($record); + + foreach ($rules as $rule) { + if (trim((string) ($rule['platform'] ?? '')) !== $platform) { + continue; + } + + $ruleType = self::cleanViolationType($rule['violation_type'] ?? null); + if ($ruleType !== '') { + if ($record === $ruleType || str_starts_with($record, $ruleType)) { + return $rule; + } + foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) { + $part = trim((string) $part); + if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) { + return $rule; + } + } + $looseRule = self::loose($ruleType); + if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) { + return $rule; + } + } + + foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) { + $keyword = self::loose(self::cleanViolationType($keyword)); + if ($keyword !== '' && str_contains($looseRecord, $keyword)) { + return $rule; + } + } + } + + return null; + } + + /** + * 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。 + */ + public static function deadlineFor( + ?string $violationDate, + ?string $appealDeadline, + ?int $handlingHours, + ?string $alertLevel, + ): ?string { + $appeal = trim((string) $appealDeadline); + if ($appeal !== '') { + try { + return Carbon::parse($appeal)->endOfDay()->toDateTimeString(); + } catch (Throwable) { + // 解析失败时退回按级别计算 + } + } + + $date = trim((string) $violationDate); + if ($date === '') { + return null; + } + + try { + $base = Carbon::parse($date)->startOfDay(); + } catch (Throwable) { + return null; + } + + if ($handlingHours !== null && $handlingHours > 0) { + return $base->addHours($handlingHours)->toDateTimeString(); + } + + $level = trim((string) $alertLevel); + if ($level === '') { + return null; + } + + $days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0); + + return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null; + } + + /** 是否超时:处理状态不在终态且当前时间已过处理截止。 */ + public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool + { + $status = trim((string) $handlingStatus); + if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) { + return false; + } + + $deadline = trim((string) $handlingDeadline); + if ($deadline === '') { + return false; + } + + try { + return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline)); + } catch (Throwable) { + return false; + } + } + + private function ownerFor(?string $shopName): ?string + { + $name = trim((string) $shopName); + if ($name === '') { + return null; + } + + try { + if ($this->ownerMap === null) { + $this->ownerMap = $this->storeAccounts->ownershipByStoreName(); + } + $owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? '')); + if ($owner !== '') { + return mb_substr($owner, 0, 64); + } + } catch (Throwable) { + $this->ownerMap ??= []; + } + + // 兜底:店铺档案(经营分析副本库)的店铺运营负责人。 + if (array_key_exists($name, $this->ownerFallback)) { + return $this->ownerFallback[$name]; + } + + $head = null; + try { + $row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) + ->table('ops_fys_shop_info') + ->where('shopName', $name) + ->orderByDesc('id') + ->first(['shopHead']); + $head = self::nullableText($row->shopHead ?? null, 64); + } catch (Throwable) { + $head = null; + } + + return $this->ownerFallback[$name] = $head; + } + + private static function nullableText(mixed $value, int $limit): ?string + { + $text = trim((string) $value); + + return $text === '' ? null : mb_substr($text, 0, $limit); + } +} diff --git a/app/Business/StoreViolationNotifier.php b/app/Business/StoreViolationNotifier.php new file mode 100644 index 00000000..add81683 --- /dev/null +++ b/app/Business/StoreViolationNotifier.php @@ -0,0 +1,237 @@ +pushes->instantCandidates($levels, $retryFailed, $limit); + $summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0]; + $ownerContacts = (array) config('store_violations.push.owner_contacts', []); + + foreach ($candidates as $record) { + $owner = trim((string) ($record->owner_name ?? '')); + $atMobiles = []; + if ($owner !== '' && ! empty($ownerContacts[$owner])) { + $atMobiles[] = (string) $ownerContacts[$owner]; + } + + [$title, $text] = $this->instantMessage($record); + $pushNo = $this->pushNo(); + $now = Carbon::now(); + + try { + $this->send($title, $text, $atMobiles); + $this->pushes->markPushed([(int) $record->id], $now); + $summary['succeeded']++; + $this->pushes->log([ + 'push_no' => $pushNo, + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'instant', + 'platform' => (string) ($record->platform ?? ''), + 'violation_no' => (string) ($record->violation_no ?? ''), + 'message_digest' => mb_substr($title, 0, 500), + 'mentioned' => $owner, + 'result' => 'success', + 'error' => null, + ]); + } catch (Throwable $exception) { + $summary['failed']++; + $this->pushes->markPushFailed((int) $record->id, $now); + $this->pushes->log([ + 'push_no' => $pushNo, + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'instant', + 'platform' => (string) ($record->platform ?? ''), + 'violation_no' => (string) ($record->violation_no ?? ''), + 'message_digest' => mb_substr($title, 0, 500), + 'mentioned' => $owner, + 'result' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 500), + ]); + } + } + + return $summary; + } + + /** + * @return array{pending:array,overdue:int,p2:int,sent:bool} + */ + public function pushDigest(): array + { + $data = $this->pushes->digestData(); + $pending = $data['pending']; + $overdue = $data['overdue']; + $p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + + $lines = [ + '### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')', + '- 待处理:P0 ' . (int) ($pending['P0'] ?? 0) + . ' 条 / P1 ' . (int) ($pending['P1'] ?? 0) + . ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条', + '- 已超时未认领:' . $overdue->count() . ' 条', + ]; + foreach ($overdue as $index => $record) { + $lines[] = sprintf( + ' %d. [%s] %s(责任人:%s) · 截止 %s', + $index + 1, + (string) ($record->alert_level ?? ''), + (string) ($record->shop_name ?? ''), + trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配', + (string) ($record->handling_deadline ?? ''), + ); + } + + $now = Carbon::now(); + try { + $this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []); + } catch (Throwable $exception) { + $this->pushes->log([ + 'push_no' => $this->pushNo(), + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'daily', + 'platform' => null, + 'violation_no' => null, + 'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500), + 'mentioned' => null, + 'result' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 500), + ]); + + return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false]; + } + + // 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。 + $this->pushes->markPushed($p2Ids, $now); + $this->pushes->log([ + 'push_no' => $this->pushNo(), + 'pushed_at' => $now, + 'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'), + 'message_type' => 'daily', + 'platform' => null, + 'violation_no' => null, + 'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500), + 'mentioned' => null, + 'result' => 'success', + 'error' => null, + ]); + + return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true]; + } + + /** @return array{0:string,1:string} */ + private function instantMessage(object $record): array + { + $level = (string) ($record->alert_level ?? ''); + $levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level); + $platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? '')); + $title = '店铺违规预警(' . $levelLabel . ')'; + $lines = [ + '### 【' . $levelLabel . '】店铺违规预警', + '- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''), + '- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200), + '- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'), + '- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'), + '- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'), + '- 处理截止:' . (string) ($record->handling_deadline ?? '—'), + ]; + + $claimUrl = $this->claimUrl((int) $record->id); + if ($claimUrl !== null) { + $lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)'; + } + + return [$title, implode("\n", $lines)]; + } + + private function claimUrl(int $recordId): ?string + { + try { + $companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1); + + return URL::signedRoute('business.store-violation-alert.claim', [ + 'record' => $recordId, + 'company_id' => $companyId, + ]); + } catch (Throwable) { + return null; + } + } + + /** @param list $atMobiles */ + private function send(string $title, string $text, array $atMobiles): void + { + $config = (array) config('store_violations.push', []); + $webhook = trim((string) ($config['webhook'] ?? '')); + if ($webhook === '') { + throw new RuntimeException('推送 Webhook 未配置。'); + } + + $url = $webhook; + $secret = trim((string) ($config['secret'] ?? '')); + 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' => array_values($atMobiles), + 'isAtAll' => (bool) ($config['at_all'] ?? 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 pushNo(): string + { + return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6)); + } +} diff --git a/app/Business/StoreViolationWorkbookImporter.php b/app/Business/StoreViolationWorkbookImporter.php index ebee242a..00391749 100644 --- a/app/Business/StoreViolationWorkbookImporter.php +++ b/app/Business/StoreViolationWorkbookImporter.php @@ -19,6 +19,7 @@ class StoreViolationWorkbookImporter public function __construct( private readonly StoreViolationMapper $mapper, private readonly StoreViolationRecordRepository $repository, + private readonly StoreViolationAlertCalculator $alerts, ) { } @@ -139,6 +140,11 @@ class StoreViolationWorkbookImporter } $updateColumns[] = 'source'; $updateColumns[] = 'updated_at'; + // 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。 + $updateColumns = array_values(array_unique(array_merge( + $updateColumns, + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ))); $payload = []; $created = 0; @@ -155,6 +161,17 @@ class StoreViolationWorkbookImporter continue; } + $matchType = trim((string) ($data['violation_type'] ?? '')) !== '' + ? (string) $data['violation_type'] + : ($data['violation_reason'] ?? null); + $data = array_merge($data, $this->alerts->calculate( + $platform, + (string) ($data['shop_name'] ?? ''), + $matchType, + $data['violation_date'] ?? null, + $data['appeal_deadline'] ?? null, + )); + foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) { if (! array_key_exists($column, $data)) { $data[$column] = null; @@ -369,6 +386,8 @@ class StoreViolationWorkbookImporter if ($has('handling_status')) { $handling = trim((string) ($raw['handling_status'] ?? '')); + $legacyMap = (array) config('store_violations.handling_status_legacy', []); + $handling = (string) ($legacyMap[$handling] ?? $handling); $options = (array) config('store_violations.handling_statuses', []); if ($handling === '') { $data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理'); @@ -380,6 +399,15 @@ class StoreViolationWorkbookImporter } } + if ($has('ignore_reason')) { + $ignoreReason = trim((string) ($raw['ignore_reason'] ?? '')); + $data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191); + } + + if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') { + $errors[] = '处理状态为“已忽略”时必须填写忽略原因'; + } + return [ 'row_number' => $rowNumber, 'status' => $errors === [] ? 'valid' : 'invalid', diff --git a/app/Console/Commands/PushStoreViolationAlerts.php b/app/Console/Commands/PushStoreViolationAlerts.php new file mode 100644 index 00000000..b0036b9c --- /dev/null +++ b/app/Console/Commands/PushStoreViolationAlerts.php @@ -0,0 +1,41 @@ +enabled()) { + $this->line('推送未配置,跳过。'); + + return self::SUCCESS; + } + + $summary = $notifier->pushInstant( + max((int) $this->option('limit'), 1), + (bool) $this->option('retry-failed'), + ); + + $this->info(sprintf( + '即时推送:待推 %d 条,成功 %d 条,失败 %d 条。', + $summary['attempted'], + $summary['succeeded'], + $summary['failed'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PushStoreViolationDigest.php b/app/Console/Commands/PushStoreViolationDigest.php new file mode 100644 index 00000000..aee87d08 --- /dev/null +++ b/app/Console/Commands/PushStoreViolationDigest.php @@ -0,0 +1,38 @@ +enabled()) { + $this->line('推送未配置,跳过。'); + + return self::SUCCESS; + } + + $summary = $notifier->pushDigest(); + + $this->info(sprintf( + '日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。', + $summary['sent'] ? '已发送' : '发送失败', + (int) ($summary['pending']['P0'] ?? 0), + (int) ($summary['pending']['P1'] ?? 0), + (int) ($summary['pending']['P2'] ?? 0), + (int) $summary['overdue'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalcStoreViolationAlerts.php b/app/Console/Commands/RecalcStoreViolationAlerts.php new file mode 100644 index 00000000..1698581e --- /dev/null +++ b/app/Console/Commands/RecalcStoreViolationAlerts.php @@ -0,0 +1,73 @@ +available()) { + $this->error('统一表 store_violation_records 尚未建立。'); + + return self::FAILURE; + } + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $chunk = max(min((int) $this->option('chunk'), 2000), 100); + $platform = trim((string) $this->option('platform')); + $total = 0; + + $query = $connection->table(StoreViolationRecordRepository::TABLE) + ->select(['id', 'platform', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline']) + ->orderBy('id'); + if ($platform !== '') { + $query->where('platform', $platform); + } + + $query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void { + $updates = []; + foreach ($rows as $row) { + $matchType = trim((string) ($row->violation_type ?? '')) !== '' + ? (string) $row->violation_type + : ($row->violation_reason ?? null); + + $updates[] = ['id' => $row->id] + $calculator->calculate( + (string) $row->platform, + $row->shop_name, + $matchType, + $row->violation_date ? (string) $row->violation_date : null, + $row->appeal_deadline ? (string) $row->appeal_deadline : null, + ); + } + + if ($updates !== []) { + $connection->table(StoreViolationRecordRepository::TABLE)->upsert( + $updates, + ['id'], + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ); + $total += count($updates); + } + }); + + $this->info(sprintf('预警重算完成:%d 行。', $total)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncStoreViolations.php b/app/Console/Commands/SyncStoreViolations.php index 9dfa48f4..a05743ac 100644 --- a/app/Console/Commands/SyncStoreViolations.php +++ b/app/Console/Commands/SyncStoreViolations.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Business\StoreViolationAlertCalculator; use App\Business\StoreViolationMapper; use Illuminate\Console\Command; use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository; @@ -23,7 +24,11 @@ class SyncStoreViolations extends Command protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)'; - public function handle(StoreViolationMapper $mapper, StoreViolationRecordRepository $repository): int + public function handle( + StoreViolationMapper $mapper, + StoreViolationRecordRepository $repository, + StoreViolationAlertCalculator $calculator, + ): int { if (! $repository->available()) { $this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。'); @@ -74,6 +79,11 @@ class SyncStoreViolations extends Command $updateColumns = $extraColumns === [] ? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS : array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns)); + // 预警派生字段(级别/责任人/处理截止等)每次都随同步重算写入。 + $updateColumns = array_values(array_unique(array_merge( + $updateColumns, + StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS, + ))); try { while (true) { @@ -118,6 +128,14 @@ class SyncStoreViolations extends Command continue; } + $mapped = array_merge($mapped, $calculator->calculate( + (string) $mapped['platform'], + $mapped['shop_name'], + $mapped['violation_type'], + $mapped['violation_date'], + $mapped['appeal_deadline'], + )); + $payload[] = $mapped + [ 'synced_at' => $now, 'created_at' => $now, diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 43feda7a..64d8d09b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -65,6 +65,17 @@ class Kernel extends ConsoleKernel ->withoutOverlapping(60) ->runInBackground(); + // 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总。 + $schedule->command('store-violations:push-instant') + ->cron('*/5 9-21 * * *') + ->withoutOverlapping(5) + ->runInBackground(); + + $schedule->command('store-violations:push-digest') + ->dailyAt('10:00') + ->withoutOverlapping(30) + ->runInBackground(); + $schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0') ->dailyAt('00:00') ->withoutOverlapping(180) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 8d3a7592..b33bbf21 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -13,6 +13,7 @@ use App\Business\UpstreamBusinessInformation; use App\Business\StoreReceivingAccountTemplate; use App\Business\StoreReceivingAccountWorkbookImporter; use App\Business\StoreViolationWorkbookImporter; +use App\Business\StoreViolationAlertCalculator; use App\Business\WithdrawalManagementReport; use App\Jobs\Common\InventoryReportExport; use App\Models\Banking\MarketingAccountBill; @@ -3008,6 +3009,7 @@ class Pages extends Controller 'account-mapping' => $this->contentForAccountMapping(), 'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(), 'store-violation-alert' => $this->contentForStoreViolationAlerts(), + 'preview-statistics' => $this->contentForPreviewStatistics(), 'planting-details' => $this->contentForPlantingDetails(), 'jst-order-labels' => $this->contentForJstOrderLabels(), 'store-report-data-logic' => $this->prepareStoreReportLogic(), @@ -3323,7 +3325,7 @@ class Pages extends Controller 'storeViolationFilters' => $filters, 'storeViolationFilterOptions' => $error === null ? $repository->filterOptions($activePlatform) - : ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []], + : ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []], 'storeViolationPlatformCounts' => $error === null ? $repository->platformCounts() : [], 'storeViolationSync' => $repository->syncStatus(), 'storeViolationError' => $error, @@ -3334,14 +3336,185 @@ class Pages extends Controller /** @return array */ private function storeViolationFilters(Request $request): array { - return [ + $owner = mb_substr(trim((string) $request->query('owner')), 0, 64); + $ownerIsMe = in_array(mb_strtolower($owner), ['me', '我的'], true); + if ($ownerIsMe) { + $owner = mb_substr(trim((string) (auth()->user()->name ?? '')), 0, 64); + } + + $handlingStatus = mb_substr(trim((string) $request->query('handling_status')), 0, 32); + $view = trim((string) $request->query('view')) === 'all' ? 'all' : 'default'; + $explicit = trim((string) $request->query('keyword')) !== '' + || trim((string) $request->query('status')) !== '' + || $handlingStatus !== '' + || trim((string) $request->query('category')) !== '' + || $this->dateFilter($request->query('date_from')) !== '' + || $this->dateFilter($request->query('date_to')) !== '' + || trim((string) $request->query('level')) !== '' + || $owner !== '' + || trim((string) $request->query('overdue')) === '1'; + + $filters = [ 'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100), 'status' => mb_substr(trim((string) $request->query('status')), 0, 64), - 'handling_status' => mb_substr(trim((string) $request->query('handling_status')), 0, 32), + 'handling_status' => $handlingStatus, 'category' => mb_substr(trim((string) $request->query('category')), 0, 64), 'date_from' => $this->dateFilter($request->query('date_from')), 'date_to' => $this->dateFilter($request->query('date_to')), + 'level' => mb_substr(trim((string) $request->query('level')), 0, 8), + 'owner' => $owner, + 'owner_is_me' => $ownerIsMe ? '1' : '', + 'overdue' => trim((string) $request->query('overdue')) === '1' ? '1' : '', + 'view' => $view, + 'has_explicit' => $explicit ? '1' : '', ]; + + // 默认视图:只展示「需提醒 + 未处理」,避免历史数据淹没预警; + // 其余数据仍可通过「全部数据」查看。 + if ($view === 'default') { + $filters['needs_alert'] = '1'; + if ($filters['handling_status'] === '') { + $filters['handling_status'] = (string) config('store_violations.default_handling_status', '未处理'); + } + } + + return $filters; + } + + /** 财务经营风险管控 › 预览统计:顶部 4 张 KPI 卡 + 需提醒且未处理的明细预览。 */ + protected function contentForPreviewStatistics(): array + { + $request = request(); + $platforms = (array) config('store_violations.platforms', []); + $requested = (string) $request->query('platform', (string) array_key_first($platforms)); + $activePlatform = array_key_exists($requested, $platforms) ? $requested : (string) array_key_first($platforms); + + $repository = app(StoreViolationRecordRepository::class); + $limit = min(max((int) setting('default.list_limit', 25), 10), 100); + $error = null; + $stats = []; + $counts = []; + $panels = []; + + if (! $repository->available()) { + $error = '店铺违规统一表尚未建立,暂时无法读取数据。'; + } else { + try { + $stats = $repository->statsByPlatform(); + $counts = $repository->platformCounts(); + } catch (Throwable $exception) { + report($exception); + $error = '店铺违规数据暂时无法读取,请稍后重试。'; + } + } + + foreach (array_keys($platforms) as $slug) { + $page = $slug === $activePlatform ? max((int) $request->query('page', 1), 1) : 1; + $records = Collection::make(); + $total = 0; + + if ($error === null) { + try { + $result = $repository->paginate((string) $slug, [ + 'needs_alert' => true, + 'handling_status' => (string) config('store_violations.default_handling_status', '未处理'), + ], $page, $limit); + $records = Collection::make($result['data']); + $total = (int) $result['total']; + } catch (Throwable $exception) { + report($exception); + $error = '店铺违规数据暂时无法读取,请稍后重试。'; + } + } + + $panels[$slug] = [ + 'rows' => new LengthAwarePaginator($records, $total, $limit, $page, [ + 'path' => $request->url(), + 'query' => array_merge($request->query(), ['platform' => (string) $slug]), + ]), + 'total' => $total, + 'stats' => $stats[$slug] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0], + ]; + } + + return [ + 'view' => 'business.pages.financial-risk-control.preview-statistics', + 'storeViolationPlatforms' => $platforms, + 'storeViolationActivePlatform' => $activePlatform, + 'storeViolationPlatformCounts' => $counts, + 'storeViolationPanels' => $panels, + 'storeViolationError' => $error, + ]; + } + + /** 店铺违规信息预警:行内更新处理状态(已忽略必填原因)。 */ + public function updateStoreViolation(Request $request) + { + $data = $request->validate([ + 'id' => ['required', 'integer'], + 'handling_status' => ['required', 'string', \Illuminate\Validation\Rule::in((array) config('store_violations.handling_statuses', []))], + 'ignore_reason' => ['nullable', 'string', 'max:191'], + 'points_deducted' => ['nullable', 'numeric', 'min:0', 'max:9999'], + 'penalty_date' => ['nullable', 'date_format:Y-m-d'], + ]); + + $ignoreReason = trim((string) ($data['ignore_reason'] ?? '')); + if ($data['handling_status'] === '已忽略' && $ignoreReason === '') { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'ignore_reason' => '已忽略必须填写忽略原因。', + ]); + } + + $repository = app(StoreViolationRecordRepository::class); + abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。'); + abort_if($repository->findById((int) $data['id']) === null, 404); + + $updates = [ + 'handling_status' => $data['handling_status'], + 'ignore_reason' => $data['handling_status'] === '已忽略' ? $ignoreReason : null, + 'updated_at' => now(), + ]; + if ($request->has('points_deducted')) { + $updates['points_deducted'] = $data['points_deducted'] ?? null; + } + if ($request->has('penalty_date')) { + $updates['penalty_date'] = $data['penalty_date'] ?? null; + } + + $repository->updateFields((int) $data['id'], $updates); + + return back(302, [], route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + ]))->with('success', '处理状态已更新。'); + } + + /** 店铺违规信息预警:群消息「去认领」签名链接,幂等置为处理中。 */ + public function claimStoreViolation(Request $request, int $record) + { + abort_unless($request->hasValidSignature(), 403, '链接已过期,请从最新的提醒消息进入。'); + + $repository = app(StoreViolationRecordRepository::class); + abort_unless($repository->available(), 503, '店铺违规统一表尚未建立。'); + $row = $repository->findById($record); + abort_if($row === null, 404); + + $defaultStatus = (string) config('store_violations.default_handling_status', '未处理'); + if (trim((string) ($row->handling_status ?? '')) === $defaultStatus) { + $repository->updateFields($record, [ + 'handling_status' => '处理中', + 'updated_at' => now(), + ]); + } + + return redirect() + ->route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => (string) ($row->platform ?? ''), + 'keyword' => (string) ($row->violation_no ?? ''), + ]) + ->with('success', '已认领该违规记录,处理状态已更新为“处理中”。'); } /** 店铺违规信息预警:按当前筛选导出统一表头(Excel)。 */ @@ -3383,13 +3556,22 @@ class Pages extends Controller if ($key === 'points_deducted' && $value !== null) { $value = (float) $value; } + if ($key === 'alert_level' && $value !== null) { + $value = (string) (config('store_violations.alert_levels.' . $value . '.label') ?? $value); + } + if ($key === 'is_overdue') { + $value = StoreViolationAlertCalculator::isOverdue( + $row->handling_status ?? null, + $row->handling_deadline ?? null, + ) ? '已超时' : '未超时'; + } $values[] = $value === null ? '' : $value; } $sheet->fromArray($values, null, 'A' . $rowNumber); $rowNumber++; } - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 36, 'E' => 28, 'F' => 24, 'G' => 14, 'H' => 10, 'I' => 16, 'J' => 14, 'K' => 16, 'L' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3415,7 +3597,7 @@ class Pages extends Controller $headers = array_map(static fn (array $column): string => (string) $column['label'], $columns); $sheet->fromArray($headers, null, 'A1'); $sheet->fromArray( - ['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'], + ['示例·某某旗舰店', 'P1-较重', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '示例·张三', '待处理', '', '2026-10-11', '2026-10-13 23:59:59', '未超时', '未推送', '未处理', ''], null, 'A2', ); @@ -3426,7 +3608,7 @@ class Pages extends Controller 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(24); - foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 36, 'E' => 28, 'F' => 24, 'G' => 14, 'H' => 10, 'I' => 16, 'J' => 14, 'K' => 16, 'L' => 12] as $column => $width) { + foreach (['A' => 30, 'B' => 12, 'C' => 36, 'D' => 14, 'E' => 36, 'F' => 28, 'G' => 24, 'H' => 14, 'I' => 10, 'J' => 12, 'K' => 16, 'L' => 14, 'M' => 16, 'N' => 18, 'O' => 12, 'P' => 12, 'Q' => 14, 'R' => 24] as $column => $width) { $sheet->getColumnDimension($column)->setWidth($width); } $sheet->freezePane('A2'); @@ -3439,12 +3621,18 @@ class Pages extends Controller ['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'], ['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'], ['违规分类', '选填。留空时按违规类型关键词自动归类。'], + ['预警级别', '自动计算。按「平台+违规类型」匹配违规字典;可留空,导入时自动生成。'], + ['责任人', '自动计算。按店铺名称关联店铺运营负责人;可留空,导入时自动生成。'], ['违规原因', '选填。平台展示的违规原因/详细说明。'], ['处置方式', '选填。平台对违规的处置/处理方式。'], ['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'], ['扣分', '选填。数字,例如 2 或 0.5。'], ['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'], - ['处理状态', '选填。可选值:' . $handling . ';留空按“未处理”写入。'], + ['处理截止', '自动计算。申诉截止日期非空时取其当天 23:59:59;否则按级别(P0+1 / P1+2 / P2+3 天)计算。'], + ['是否超时', '自动计算。处理状态非“处理完成/已忽略”且已过处理截止时显示“已超时”。'], + ['推送状态', '系统回写。导入时忽略该列。'], + ['处理状态', '选填。可选值:' . $handling . ';选“已忽略”时必须填写忽略原因;留空按“未处理”写入。'], + ['忽略原因', '选填。处理状态为“已忽略”时必填。'], ['平台', '无需填写。导入时按当前页签决定平台。'], ['提示', '请保留第一行表头;示例行可直接替换为实际数据。'], ], null, 'A1'); @@ -3454,7 +3642,7 @@ class Pages extends Controller ]); $notes->getColumnDimension('A')->setWidth(34); $notes->getColumnDimension('B')->setWidth(72); - $notes->getStyle('B1:B13')->getAlignment()->setWrapText(true); + $notes->getStyle('B1:B19')->getAlignment()->setWrapText(true); (new Xlsx($spreadsheet))->save('php://output'); }, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [ diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index e5f02725..5edcaa50 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -149,25 +149,30 @@ class SidebarPermissions } unset($section); - // 「财务经营风险管控」新增「流程统计」子页面:按页面键保存过精确权限的账号只会 - // 有 store-violation-alert,会看不到新页面;这里按「能看店铺违规预警 就能看流程统计」 - // 兼容旧配置(两页同属财务经营风险管控权限)。 + // 「财务经营风险管控」新增「预览统计 / 流程统计」子页面:按页面键保存过精确权限的 + // 账号只会有一条或两条,会看不到新页面;这里按「能看店铺违规预警 就能看新增页」 + // 兼容旧配置(同属财务经营风险管控权限)。 foreach ($navigation as &$riskSection) { if (($riskSection['slug'] ?? null) !== 'financial-risk-control') { continue; } $pageSlugs = array_column($riskSection['pages'] ?? [], 'slug'); - if (! in_array('store-violation-alert', $pageSlugs, true) || in_array('process-statistics', $pageSlugs, true)) { + if (! in_array('store-violation-alert', $pageSlugs, true)) { continue; } $configuredPages = collect(BusinessNavigation::all()) ->firstWhere('slug', 'financial-risk-control')['pages'] ?? []; - $statisticsPage = collect($configuredPages)->firstWhere('slug', 'process-statistics'); - if ($statisticsPage) { - $riskSection['pages'][] = $statisticsPage; + foreach ($configuredPages as $configuredPage) { + $configuredSlug = (string) ($configuredPage['slug'] ?? ''); + if (! in_array($configuredSlug, ['preview-statistics', 'process-statistics'], true)) { + continue; + } + if (! in_array($configuredSlug, $pageSlugs, true)) { + $riskSection['pages'][] = $configuredPage; + } } } unset($riskSection); diff --git a/config/business_navigation.php b/config/business_navigation.php index c1bdf4d0..8dfb0429 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -871,6 +871,7 @@ $navigation = [ 'icon' => 'security', 'pages' => [ ['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'], + ['slug' => 'preview-statistics', 'title' => '预览统计'], [ 'slug' => 'process-statistics', 'title' => '流程统计', diff --git a/config/store_violations.php b/config/store_violations.php index b4e647e7..b15153a6 100644 --- a/config/store_violations.php +++ b/config/store_violations.php @@ -12,8 +12,10 @@ return [ // 统一表头(顺序即页面、导出、导入模板的列顺序) + // auto=自动计算列(导入忽略、导出可见);virtual=查询时计算;system=系统回写(导入忽略)。 'columns' => [ ['key' => 'shop_name', 'label' => '店铺名'], + ['key' => 'alert_level', 'label' => '预警级别', 'auto' => true], ['key' => 'violation_type', 'label' => '违规类型'], ['key' => 'violation_category', 'label' => '违规分类'], ['key' => 'violation_reason', 'label' => '违规原因'], @@ -21,10 +23,15 @@ return [ ['key' => 'violation_no', 'label' => '违规单号'], ['key' => 'violation_date', 'label' => '违规日期'], ['key' => 'points_deducted', 'label' => '扣分'], + ['key' => 'owner_name', 'label' => '责任人', 'auto' => true], ['key' => 'status', 'label' => '状态'], ['key' => 'penalty_date', 'label' => '处罚日期'], ['key' => 'appeal_deadline', 'label' => '申诉截止日期'], + ['key' => 'handling_deadline', 'label' => '处理截止', 'auto' => true], + ['key' => 'is_overdue', 'label' => '是否超时', 'virtual' => true], + ['key' => 'push_status', 'label' => '推送状态', 'system' => true], ['key' => 'handling_status', 'label' => '处理状态'], + ['key' => 'ignore_reason', 'label' => '忽略原因'], ], // 页面平台页签;source_values 为上游/导入文件中平台字段的取值 @@ -38,8 +45,37 @@ return [ ], // 处理状态(本地维护) - 'handling_statuses' => ['未处理', '处理中', '已处理', '无需处理'], + 'handling_statuses' => ['未处理', '处理中', '处理完成', '已忽略'], 'default_handling_status' => '未处理', + // 历史取值兼容:导入时归一化到新四档 + 'handling_status_legacy' => ['已处理' => '处理完成', '无需处理' => '已忽略'], + + // 预警级别(页面标签颜色)与处理时效默认(违规日期起算的天数,当天 23:59:59 到期) + 'alert_levels' => [ + 'P0' => ['label' => 'P0-严重', 'tone' => 'is-danger'], + 'P1' => ['label' => 'P1-较重', 'tone' => 'is-warning'], + 'P2' => ['label' => 'P2-提醒', 'tone' => 'is-success'], + ], + 'alert_default_days' => ['P0' => 1, 'P1' => 2, 'P2' => 3], + + // 推送状态 + 'push_statuses' => ['未推送', '已推送', '推送失败'], + + // 默认视图:只展示需提醒且未处理的记录,其余数据只入库不打扰 + 'default_view' => ['needs_alert' => true, 'handling_status' => '未处理'], + + // 群机器人推送(先留接口,配置后启用;窗口从每天 09:00 开始) + 'push' => [ + 'enabled' => false, + 'channel' => 'dingtalk_robot', + 'webhook' => '', + 'secret' => '', + 'window_start' => '09:00', + 'window_end' => '21:00', + 'instant_levels' => ['P0', 'P1'], + 'digest_time' => '10:00', + 'at_all' => false, + ], // 导入表头别名 → 统一列键(不区分大小写、忽略空格) 'header_aliases' => [ @@ -48,6 +84,12 @@ return [ 'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'], 'violation_reason' => ['违规原因', '违规说明', '违规详情', 'violation_reason', 'reason'], 'disposition' => ['处置方式', '处置措施', '处置结果', '处理方式', 'disposition', 'handling_method'], + 'alert_level' => ['预警级别', '预警等级', 'alert_level'], + 'owner_name' => ['责任人', '店铺负责人', '运营负责人', 'owner_name'], + 'handling_deadline' => ['处理截止', '处理截止时间', '处理时限', 'handling_deadline'], + 'is_overdue' => ['是否超时', '是否逾期', 'is_overdue'], + 'push_status' => ['推送状态', 'push_status'], + 'ignore_reason' => ['忽略原因', '忽略理由', 'ignore_reason'], 'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'], 'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'], 'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'], diff --git a/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php b/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php new file mode 100644 index 00000000..d6125d21 --- /dev/null +++ b/database/migrations/2026_10_13_200000_add_alert_workflow_fields_to_store_violation_records.php @@ -0,0 +1,104 @@ +connection()); + $table = 'store_violation_records'; + + if (! $schema->hasTable($table)) { + return; + } + + $columns = [ + 'owner_name' => fn (Blueprint $t) => $t->string('owner_name', 64)->nullable()->after('shop_name'), + 'alert_level' => fn (Blueprint $t) => $t->string('alert_level', 8)->nullable()->after('violation_category'), + 'needs_alert' => fn (Blueprint $t) => $t->boolean('needs_alert')->default(false)->after('alert_level'), + 'alert_rule_id' => fn (Blueprint $t) => $t->unsignedBigInteger('alert_rule_id')->nullable()->after('needs_alert'), + 'alert_action' => fn (Blueprint $t) => $t->string('alert_action', 500)->nullable(), + 'alert_channel' => fn (Blueprint $t) => $t->string('alert_channel', 64)->nullable(), + 'handling_hours' => fn (Blueprint $t) => $t->unsignedInteger('handling_hours')->nullable(), + 'handling_deadline' => fn (Blueprint $t) => $t->dateTime('handling_deadline')->nullable()->after('appeal_deadline'), + 'ignore_reason' => fn (Blueprint $t) => $t->string('ignore_reason', 191)->nullable()->after('handling_status'), + 'push_status' => fn (Blueprint $t) => $t->string('push_status', 16)->default('未推送'), + 'last_pushed_at' => fn (Blueprint $t) => $t->dateTime('last_pushed_at')->nullable(), + ]; + + $missing = []; + foreach ($columns as $column => $definition) { + if (! $schema->hasColumn($table, $column)) { + $missing[$column] = $definition; + } + } + + if ($missing !== []) { + $schema->table($table, function (Blueprint $blueprint) use ($missing): void { + foreach ($missing as $definition) { + $definition($blueprint); + } + }); + } + + $indexes = [ + 'store_violations_platform_alert_status_index' => ['platform', 'needs_alert', 'handling_status'], + 'store_violations_alert_deadline_index' => ['needs_alert', 'handling_status', 'handling_deadline'], + 'store_violations_platform_owner_index' => ['platform', 'owner_name'], + 'store_violations_push_level_index' => ['push_status', 'alert_level'], + ]; + + foreach ($indexes as $name => $indexColumns) { + if (! $schema->hasIndex($table, $name)) { + $schema->table($table, function (Blueprint $blueprint) use ($indexColumns, $name): void { + $blueprint->index($indexColumns, $name); + }); + } + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $table = 'store_violation_records'; + + if (! $schema->hasTable($table)) { + return; + } + + foreach ([ + 'store_violations_platform_alert_status_index', + 'store_violations_alert_deadline_index', + 'store_violations_platform_owner_index', + 'store_violations_push_level_index', + ] as $name) { + if ($schema->hasIndex($table, $name)) { + $schema->table($table, function (Blueprint $blueprint) use ($name): void { + $blueprint->dropIndex($name); + }); + } + } + + foreach ([ + 'owner_name', 'alert_level', 'needs_alert', 'alert_rule_id', 'alert_action', 'alert_channel', + 'handling_hours', 'handling_deadline', 'ignore_reason', 'push_status', 'last_pushed_at', + ] as $column) { + if ($schema->hasColumn($table, $column)) { + $schema->table($table, function (Blueprint $blueprint) use ($column): void { + $blueprint->dropColumn($column); + }); + } + } + } +}; diff --git a/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php b/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php new file mode 100644 index 00000000..ab5b7fde --- /dev/null +++ b/database/migrations/2026_10_13_200100_create_store_violation_rules_table.php @@ -0,0 +1,49 @@ +connection()); + + if ($schema->hasTable('store_violation_rules')) { + return; + } + + $schema->create('store_violation_rules', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('platform', 32)->comment('平台 slug:taobao/alibaba/jd/jingxi/pinduoduo/douyin'); + $table->string('violation_category', 64)->nullable()->comment('字典分类,命中后可用于校准记录分类'); + $table->string('violation_type', 500)->nullable()->comment('匹配用类型:清洗后相等或前缀匹配'); + $table->string('match_keywords', 500)->nullable()->comment('补充关键词,|、/、顿号分隔,包含任一即命中'); + $table->string('alert_level', 8)->nullable()->comment('P0/P1/P2;为空时命中后按 P2 处理'); + $table->boolean('needs_alert')->default(true)->comment('是否需要提醒;否=只入库不推送'); + $table->string('suggested_action', 500)->nullable()->comment('建议处理动作'); + $table->unsignedInteger('handling_hours')->nullable()->comment('处理时效(小时);为空时按级别默认'); + $table->string('remind_channel', 191)->nullable()->comment('提醒方式'); + $table->unsignedInteger('sort')->default(0)->comment('匹配顺序,越小越先匹配'); + $table->boolean('enabled')->default(true); + $table->string('remark', 191)->nullable(); + $table->timestamps(); + + $table->index(['platform', 'enabled', 'sort'], 'store_violation_rules_match_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('store_violation_rules'); + } +}; diff --git a/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php b/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php new file mode 100644 index 00000000..4f1f5b16 --- /dev/null +++ b/database/migrations/2026_10_13_200200_create_store_violation_push_logs_table.php @@ -0,0 +1,48 @@ +connection()); + + if ($schema->hasTable('store_violation_push_logs')) { + return; + } + + $schema->create('store_violation_push_logs', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('push_no', 32)->unique('store_violation_push_logs_no_unique'); + $table->dateTime('pushed_at')->nullable(); + $table->string('channel', 32)->default('dingtalk_robot'); + $table->string('message_type', 16)->default('instant')->comment('instant/daily'); + $table->string('platform', 32)->nullable(); + $table->string('violation_no', 100)->nullable(); + $table->string('message_digest', 500)->nullable(); + $table->string('mentioned', 191)->nullable(); + $table->string('result', 16)->default('success')->comment('success/failed'); + $table->string('error', 500)->nullable(); + $table->timestamps(); + + $table->index(['violation_no', 'message_type'], 'store_violation_push_logs_no_type_index'); + $table->index('pushed_at', 'store_violation_push_logs_pushed_at_index'); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('store_violation_push_logs'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 3ad13a1f..abe0128c 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -199,6 +199,9 @@ CRON_TZ=Asia/Shanghai 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 # 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 +# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。 +*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1 +0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 110b1445..fc040f90 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -203,6 +203,9 @@ CRON_TZ=Asia/Shanghai 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 # 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。 30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1 +# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。 +*/5 9-21 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-instant --quiet >/dev/null 2>&1 +0 10 * * * root /usr/bin/flock -n /run/caiwu-violation-push.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:push-digest --quiet >/dev/null 2>&1 # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 diff --git a/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php new file mode 100644 index 00000000..d6f4b6b8 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreViolationPushRepository.php @@ -0,0 +1,128 @@ +connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + /** + * 即时推送候选:需提醒 + 未处理 + 未推送 + 指定级别。 + * + * @param list $levels + * @return Collection + */ + public function instantCandidates(array $levels, bool $retryFailed, int $limit): Collection + { + return $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('needs_alert', true) + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->whereIn('alert_level', $levels) + ->where(function ($query) use ($retryFailed): void { + $query->where('push_status', '未推送'); + if ($retryFailed) { + $query->orWhere('push_status', '推送失败'); + } + }) + ->orderByRaw("CASE alert_level WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 ELSE 2 END") + ->orderBy('id') + ->limit(max($limit, 1)) + ->get(); + } + + /** + * 每日汇总取数:各级别待处理数量与超时未认领记录。 + * + * @return array{pending:array,overdue:Collection,p2_pending:Collection} + */ + public function digestData(int $overdueLimit = 20): array + { + $defaultStatus = (string) config('store_violations.default_handling_status', '未处理'); + + $pending = []; + foreach ($this->connection->table(StoreViolationRecordRepository::TABLE) + ->selectRaw('alert_level, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('handling_status', $defaultStatus) + ->groupBy('alert_level') + ->get() as $row) { + $pending[(string) $row->alert_level] = (int) $row->aggregate; + } + + $overdue = $this->connection->table(StoreViolationRecordRepository::TABLE) + ->whereIn('alert_level', array_keys((array) config('store_violations.alert_levels', []))) + ->whereNotIn('handling_status', StoreViolationRecordRepository::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', Carbon::now()) + ->where('needs_alert', true) + ->orderBy('handling_deadline') + ->limit(max($overdueLimit, 1)) + ->get(['id', 'platform', 'shop_name', 'owner_name', 'alert_level', 'violation_type', 'handling_deadline']); + + $p2 = $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('needs_alert', true) + ->where('alert_level', 'P2') + ->where('handling_status', $defaultStatus) + ->where('push_status', '未推送') + ->orderBy('id') + ->get(['id']); + + return ['pending' => $pending, 'overdue' => $overdue, 'p2_pending' => $p2]; + } + + /** @param list $ids */ + public function markPushed(array $ids, Carbon $at): void + { + if ($ids === []) { + return; + } + + foreach (array_chunk($ids, 500) as $chunk) { + $this->connection->table(StoreViolationRecordRepository::TABLE) + ->whereIn('id', $chunk) + ->update(['push_status' => '已推送', 'last_pushed_at' => $at, 'updated_at' => $at]); + } + } + + public function markPushFailed(int $id, Carbon $at): void + { + $this->connection->table(StoreViolationRecordRepository::TABLE) + ->where('id', $id) + ->update(['push_status' => '推送失败', 'last_pushed_at' => $at, 'updated_at' => $at]); + } + + /** @param array $entry */ + public function log(array $entry): void + { + if (! $this->available()) { + return; + } + + $now = now(); + $this->connection->table(self::LOG_TABLE)->insert($entry + [ + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function available(): bool + { + return \Illuminate\Support\Facades\Schema::connection($this->connection->getName()) + ->hasTable(self::LOG_TABLE); + } +} diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php index 987285ba..458d4f5e 100644 --- a/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreViolationRecordRepository.php @@ -36,6 +36,21 @@ class StoreViolationRecordRepository 'updated_at', ]; + /** 预警派生字段(同步/导入/重算时自动计算;本地工作流字段不受影响) */ + public const ALERT_DERIVED_COLUMNS = [ + 'needs_alert', + 'alert_level', + 'alert_rule_id', + 'alert_action', + 'alert_channel', + 'handling_hours', + 'owner_name', + 'handling_deadline', + ]; + + /** 视为已终结的处理状态(不再计入超时) */ + public const FINAL_HANDLING_STATUSES = ['处理完成', '已忽略', '已处理', '无需处理']; + private Connection $connection; public function __construct() @@ -68,6 +83,25 @@ class StoreViolationRecordRepository return $this->available() ? (int) $this->connection->table(self::TABLE)->count() : 0; } + public function findById(int $id): ?object + { + if (! $this->available() || $id <= 0) { + return null; + } + + return $this->connection->table(self::TABLE)->where('id', $id)->first(); + } + + /** @param array $fields */ + public function updateFields(int $id, array $fields): void + { + if (! $this->available() || $fields === []) { + return; + } + + $this->connection->table(self::TABLE)->where('id', $id)->update($fields); + } + /** @return array{code:string,last_succeeded_at:?string,row_count:int,last_error:?string} */ public function syncStatus(): array { @@ -152,14 +186,41 @@ class StoreViolationRecordRepository $query->whereDate('violation_date', '<=', $filters['date_to']); } + if (($filters['needs_alert'] ?? null) !== null && $filters['needs_alert'] !== '') { + $query->where('needs_alert', (bool) $filters['needs_alert']); + } + + $level = trim((string) ($filters['level'] ?? '')); + if ($level !== '') { + $query->where('alert_level', $level); + } + + $owner = trim((string) ($filters['owner'] ?? '')); + if ($owner !== '') { + $query->where('owner_name', $owner); + } + + if (! empty($filters['overdue'])) { + $query->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', now()); + } + return $query; } - /** @return array{statuses:list,handling_statuses:list,categories:list,shops:list} */ + /** @return array{statuses:list,handling_statuses:list,categories:list,shops:list,owners:list,levels:list} */ public function filterOptions(string $platform): array { if (! $this->available()) { - return ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []]; + return [ + 'statuses' => [], + 'handling_statuses' => [], + 'categories' => [], + 'shops' => [], + 'owners' => [], + 'levels' => [], + ]; } $distinct = fn (string $column): array => $this->connection->table(self::TABLE) @@ -176,6 +237,17 @@ class StoreViolationRecordRepository 'statuses' => $distinct('status'), 'handling_statuses' => $distinct('handling_status'), 'categories' => $distinct('violation_category'), + 'owners' => $this->connection->table(self::TABLE) + ->where('platform', $platform) + ->whereNotNull('owner_name') + ->where('owner_name', '<>', '') + ->orderBy('owner_name') + ->distinct() + ->limit(500) + ->pluck('owner_name') + ->map(static fn (mixed $value): string => (string) $value) + ->all(), + 'levels' => array_keys((array) config('store_violations.alert_levels', [])), 'shops' => $this->connection->table(self::TABLE) ->where('platform', $platform) ->where('shop_name', '<>', '') @@ -188,6 +260,70 @@ class StoreViolationRecordRepository ]; } + /** + * 预览统计:各平台 4 个指标(今日新增 / 待处理 / P0未处理 / 已超时)。 + * + * @return array + */ + public function statsByPlatform(): array + { + $result = []; + if (! $this->available()) { + return $result; + } + + $ensure = static function (array &$result, string $platform): void { + $result[$platform] ??= ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0]; + }; + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('created_at', '>=', now()->startOfDay()) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['new_today'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['pending'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->where('needs_alert', true) + ->where('alert_level', 'P0') + ->where('handling_status', (string) config('store_violations.default_handling_status', '未处理')) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['p0_pending'] = (int) $row->aggregate; + } + + $grouped = $this->connection->table(self::TABLE) + ->selectRaw('platform, COUNT(*) AS aggregate') + ->whereNotIn('handling_status', self::FINAL_HANDLING_STATUSES) + ->whereNotNull('handling_deadline') + ->where('handling_deadline', '<', now()) + ->groupBy('platform') + ->get(); + foreach ($grouped as $row) { + $ensure($result, (string) $row->platform); + $result[$row->platform]['overdue'] = (int) $row->aggregate; + } + + return $result; + } + /** * 统一表头导出用的行数据(流式读取,避免一次性载入)。 * diff --git a/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php b/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php new file mode 100644 index 00000000..c80d952f --- /dev/null +++ b/modules/EcommerceFinance/Repositories/StoreViolationRuleRepository.php @@ -0,0 +1,58 @@ +connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } + + public function available(): bool + { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } + + /** @return list> 启用中的规则,按 sort、id 升序 */ + public function activeRules(): array + { + if (! $this->available()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->where('enabled', true) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + } + + /** @return list> 全部规则(后台维护用),按 sort、id 升序 */ + public function all(): array + { + if (! $this->available()) { + return []; + } + + return $this->connection->table(self::TABLE) + ->orderBy('sort') + ->orderBy('id') + ->get() + ->map(static fn (object $row): array => (array) $row) + ->all(); + } +} diff --git a/resources/views/business/pages/financial-risk-control/index.blade.php b/resources/views/business/pages/financial-risk-control/index.blade.php index ea7b6dce..ca3c1156 100644 --- a/resources/views/business/pages/financial-risk-control/index.blade.php +++ b/resources/views/business/pages/financial-risk-control/index.blade.php @@ -3,20 +3,29 @@ $activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms); $panels = $storeViolationPanels ?? []; $filters = $storeViolationFilters ?? []; - $filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => []]; + $filterOptions = $storeViolationFilterOptions ?? ['statuses' => [], 'handling_statuses' => [], 'categories' => [], 'shops' => [], 'owners' => [], 'levels' => []]; $platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []); $sync = $storeViolationSync ?? ['code' => 'pending', 'last_succeeded_at' => null, 'row_count' => 0, 'last_error' => null]; $error = $storeViolationError ?? null; $preview = $storeViolationPreview ?? null; $columns = config('store_violations.columns', []); $handlingStatuses = config('store_violations.handling_statuses', []); - $hasFilters = collect($filters)->contains(static fn ($value): bool => is_string($value) && $value !== ''); + $alertLevels = config('store_violations.alert_levels', []); + $defaultView = ($filters['view'] ?? 'default') !== 'all'; + $hasFilters = ($filters['has_explicit'] ?? '') === '1'; $handlingTone = static fn (string $status): string => match ($status) { - '已处理' => 'is-success', + '处理完成', '已处理' => 'is-success', '处理中' => 'is-info', - '无需处理' => 'is-muted', + '已忽略', '无需处理' => 'is-muted', default => 'is-warning', }; + $levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted'); + $levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒')); + $pushTone = static fn (?string $status): string => match ($status) { + '已推送' => 'is-success', + '推送失败' => 'is-danger', + default => 'is-muted', + }; $previewTone = static fn (string $status): string => match ($status) { 'new' => 'is-success', 'update' => 'is-info', @@ -334,7 +343,84 @@ } .financial-risk-control__table { - min-width: 78rem; + min-width: 108rem; + } + + .financial-risk-control__viewbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem; + font-size: 0.75rem; + color: var(--vip-content-muted, #595959); + } + + .financial-risk-control__viewbar-label { + font-weight: 600; + } + + .financial-risk-control__viewbar .upstream-data-button.is-active { + border-color: var(--vip-content-accent, #B9954F); + background: var(--vip-content-accent-soft, #F7F0DE); + color: var(--vip-content-accent-dark, #856632); + } + + .financial-risk-control__row-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + } + + .financial-risk-control__row-actions button { + padding: 0.125rem 0.5rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + background: #FFFFFF; + color: var(--vip-content-ink, #171717); + cursor: pointer; + font-size: 0.75rem; + white-space: nowrap; + } + + .financial-risk-control__row-actions button:hover { + border-color: var(--vip-content-accent, #B9954F); + color: var(--vip-content-accent-dark, #856632); + } + + .financial-risk-control__ignore summary { + padding: 0.125rem 0.5rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.75rem; + list-style: none; + white-space: nowrap; + } + + .financial-risk-control__ignore summary::-webkit-details-marker { + display: none; + } + + .financial-risk-control__ignore-body { + display: flex; + gap: 0.25rem; + margin-top: 0.25rem; + } + + .financial-risk-control__ignore-body input { + width: 8rem; + padding: 0.125rem 0.375rem; + border: 1px solid var(--vip-border, #D8D2C7); + border-radius: 0.375rem; + font-size: 0.75rem; + } + + .financial-risk-control__muted-note { + margin-top: 0.125rem; + color: var(--vip-content-muted, #595959); + font-size: 0.6875rem; } .financial-risk-control__table td.is-number { @@ -444,8 +530,15 @@ 当前筛选 {{ number_format($total) }} 条{{ $hasFilters ? '(已启用筛选条件)' : '' }}。

+ +
+
@@ -473,6 +566,32 @@ @endforeach
+
+ + +
+
+ + +
+
+ + +
+ + +
+ 忽略 +
+ + +
+
+ @empty - + @if ($error) {{ $error }} @elseif ($platformTotal === 0) diff --git a/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php b/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php new file mode 100644 index 00000000..10b1261e --- /dev/null +++ b/resources/views/business/pages/financial-risk-control/preview-statistics.blade.php @@ -0,0 +1,470 @@ +@php + $platforms = $storeViolationPlatforms ?? []; + $activePlatform = $storeViolationActivePlatform ?? (string) array_key_first($platforms); + $panels = $storeViolationPanels ?? []; + $platformCounts = array_map('intval', $storeViolationPlatformCounts ?? []); + $error = $storeViolationError ?? null; + $alertLevels = config('store_violations.alert_levels', []); + $levelTone = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['tone'] ?? 'is-muted'); + $levelLabel = static fn (?string $level): string => (string) ($alertLevels[(string) $level]['label'] ?? ($level ?: '不提醒')); + $pushTone = static fn (?string $status): string => match ($status) { + '已推送' => 'is-success', + '推送失败' => 'is-danger', + default => 'is-muted', + }; +@endphp + + + +
+ @include('business.pages.partials.upstream-data-styles') + + + + @foreach ($platforms as $slug => $platform) + @php + $panel = $panels[$slug] ?? null; + $rows = $panel['rows'] ?? null; + $total = (int) ($panel['total'] ?? 0); + $stats = $panel['stats'] ?? ['new_today' => 0, 'pending' => 0, 'p0_pending' => 0, 'overdue' => 0]; + $label = $platform['label'] ?? $slug; + @endphp + + + @endforeach +
+ + diff --git a/routes/admin.php b/routes/admin.php index 3c47b50d..1df5eb1e 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -232,6 +232,8 @@ Route::prefix('workspace/financial-risk-control/store-violation-alert') Route::get('template', 'Business\Pages@storeViolationTemplate')->name('template'); Route::post('import/preview', 'Business\Pages@previewStoreViolationImport')->middleware('import')->name('import.preview'); Route::post('import/commit', 'Business\Pages@commitStoreViolationImport')->middleware('import')->name('import.commit'); + Route::post('update', 'Business\Pages@updateStoreViolation')->name('update'); + Route::get('claim/{record}', 'Business\Pages@claimStoreViolation')->name('claim'); }); Route::prefix('workspace/supplier-payables/product-prices') diff --git a/tests/Feature/Business/StoreViolationAlertPageTest.php b/tests/Feature/Business/StoreViolationAlertPageTest.php index 2b2e3259..e90f1abb 100644 --- a/tests/Feature/Business/StoreViolationAlertPageTest.php +++ b/tests/Feature/Business/StoreViolationAlertPageTest.php @@ -5,7 +5,9 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\URL; use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -35,6 +37,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->bigIncrements('id'); $table->string('platform', 32); $table->string('shop_name', 191)->default(''); + $table->string('owner_name', 64)->nullable(); $table->string('violation_type', 500)->nullable(); $table->string('violation_category', 64)->nullable(); $table->string('violation_reason', 500)->nullable(); @@ -45,15 +48,75 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->string('status', 64)->nullable(); $table->date('penalty_date')->nullable(); $table->date('appeal_deadline')->nullable(); + $table->dateTime('handling_deadline')->nullable(); $table->string('handling_status', 32)->default('未处理'); + $table->string('ignore_reason', 191)->nullable(); $table->string('source', 16)->default('crawl'); $table->string('window_name', 191)->nullable(); $table->date('crawl_date')->nullable(); $table->dateTime('synced_at')->nullable(); + $table->boolean('needs_alert')->default(false); + $table->string('alert_level', 8)->nullable(); + $table->unsignedBigInteger('alert_rule_id')->nullable(); + $table->string('alert_action', 500)->nullable(); + $table->string('alert_channel', 64)->nullable(); + $table->unsignedInteger('handling_hours')->nullable(); + $table->string('push_status', 16)->default('未推送'); + $table->dateTime('last_pushed_at')->nullable(); $table->timestamps(); $table->unique(['platform', 'violation_no']); }); + Schema::connection('business_mirror')->create('store_violation_rules', function ($table): void { + $table->bigIncrements('id'); + $table->string('platform', 32); + $table->string('violation_category', 64)->nullable(); + $table->string('violation_type', 500)->nullable(); + $table->string('match_keywords', 500)->nullable(); + $table->string('alert_level', 8)->nullable(); + $table->boolean('needs_alert')->default(true); + $table->string('suggested_action', 500)->nullable(); + $table->unsignedInteger('handling_hours')->nullable(); + $table->string('remind_channel', 191)->nullable(); + $table->unsignedInteger('sort')->default(0); + $table->boolean('enabled')->default(true); + $table->string('remark', 191)->nullable(); + $table->timestamps(); + }); + + Schema::connection('business_mirror')->create('ops_fys_shop_info', function ($table): void { + $table->bigIncrements('id'); + $table->string('shopName', 191)->nullable(); + $table->string('shopHead', 64)->nullable(); + }); + + Schema::connection('business_mirror')->create('store_violation_push_logs', function ($table): void { + $table->bigIncrements('id'); + $table->string('push_no', 32)->unique(); + $table->dateTime('pushed_at')->nullable(); + $table->string('channel', 32)->default('dingtalk_robot'); + $table->string('message_type', 16)->default('instant'); + $table->string('platform', 32)->nullable(); + $table->string('violation_no', 100)->nullable(); + $table->string('message_digest', 500)->nullable(); + $table->string('mentioned', 191)->nullable(); + $table->string('result', 16)->default('success'); + $table->string('error', 500)->nullable(); + $table->timestamps(); + }); + + config(['store_violations.push' => [ + 'enabled' => true, + 'channel' => 'dingtalk_robot', + 'webhook' => 'https://oapi.dingtalk.com/robot/send?access_token=test-token', + 'secret' => '', + 'window_start' => '09:00', + 'window_end' => '21:00', + 'instant_levels' => ['P0', 'P1'], + 'digest_time' => '10:00', + 'at_all' => false, + ]]); + Schema::connection('business_mirror')->create('business_mirror_table_states', function ($table): void { $table->string('dataset_key', 191)->primary(); $table->string('source_key', 64)->default(''); @@ -68,29 +131,79 @@ class StoreViolationAlertPageTest extends FeatureTestCase $table->timestamps(); }); + DB::connection('business_mirror')->table('store_violation_rules')->insert([ + ['id' => 1, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '缺货/无货', + 'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '联系客户补发', + 'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ['id' => 2, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单延迟发货', + 'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '致电客户协商', + 'handling_hours' => 24, 'remind_channel' => null, 'sort' => 10, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ['id' => 3, 'platform' => 'jingxi', 'violation_category' => '发票与考核', 'violation_type' => '发票违规', + 'match_keywords' => null, 'alert_level' => 'P0', 'needs_alert' => true, 'suggested_action' => '立即补开发票', + 'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ['id' => 4, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单存在揽收超时行为', + 'match_keywords' => null, 'alert_level' => null, 'needs_alert' => true, 'suggested_action' => null, + 'handling_hours' => null, 'remind_channel' => null, 'sort' => 20, 'enabled' => true, 'remark' => null, + 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], + ]); + + DB::connection('business_mirror')->table('ops_fys_shop_info')->insert([ + ['shopName' => '京东-亿家达全屋旗舰店', 'shopHead' => '张三'], + ['shopName' => '京东-卓禾卧室家具旗舰店', 'shopHead' => '李四'], + ['shopName' => '京东-纪辰家具旗舰店', 'shopHead' => '王五'], + ['shopName' => '京喜-久伴吾家具京喜自营工厂店', 'shopHead' => '幽竹'], + ]); + DB::connection('business_mirror')->table('store_violation_records')->insert([ [ - 'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'violation_type' => '延迟发货/缺货无货', - 'violation_category' => '发货履约', 'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告', + 'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'owner_name' => '张三', + 'violation_type' => '延迟发货/缺货无货', 'violation_category' => '发货履约', + 'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告', 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => 2, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => '2026-10-11', - 'handling_status' => '未处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04', + 'handling_deadline' => '2026-10-12 23:59:59', 'handling_status' => '未处理', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, + 'needs_alert' => true, 'alert_level' => 'P1', 'alert_rule_id' => 1, 'alert_action' => '联系客户补发', + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null, 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ - 'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'violation_type' => '订单存在揽收超时行为', - 'violation_category' => '发货履约', 'violation_reason' => '订单存在揽收超时行为', 'disposition' => null, + 'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'owner_name' => '李四', + 'violation_type' => '订单存在揽收超时行为', 'violation_category' => '发货履约', + 'violation_reason' => '订单存在揽收超时行为', 'disposition' => null, 'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03', 'points_deducted' => null, 'status' => '超期未申诉', 'penalty_date' => '2026-10-05', 'appeal_deadline' => '2026-10-08', - 'handling_status' => '处理中', 'source' => 'crawl', 'crawl_date' => '2026-10-04', + 'handling_deadline' => '2026-10-14 23:59:59', 'handling_status' => '处理中', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, + 'needs_alert' => true, 'alert_level' => 'P2', 'alert_rule_id' => 4, 'alert_action' => null, + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ - 'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'violation_type' => '发票违规', - 'violation_category' => '发票与考核', 'violation_reason' => null, 'disposition' => '维持原判', + 'platform' => 'jd', 'shop_name' => '京东-无提醒示例店', 'owner_name' => null, + 'violation_type' => '普通通知事项', 'violation_category' => '其他', + 'violation_reason' => null, 'disposition' => null, + 'violation_no' => 'JD202610040003', 'violation_date' => '2026-10-05', + 'points_deducted' => null, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => null, + 'handling_deadline' => null, 'handling_status' => '未处理', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-05', 'synced_at' => null, + 'needs_alert' => false, 'alert_level' => null, 'alert_rule_id' => null, 'alert_action' => null, + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null, + 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', + ], + [ + 'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'owner_name' => '幽竹', + 'violation_type' => '发票违规', 'violation_category' => '发票与考核', + 'violation_reason' => null, 'disposition' => '维持原判', 'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => null, 'status' => '已处置', 'penalty_date' => null, 'appeal_deadline' => null, - 'handling_status' => '已处理', 'source' => 'crawl', 'crawl_date' => '2026-10-04', + 'handling_deadline' => '2026-10-13 23:59:59', 'handling_status' => '处理完成', 'ignore_reason' => null, + 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, + 'needs_alert' => true, 'alert_level' => 'P0', 'alert_rule_id' => 3, 'alert_action' => '立即补开发票', + 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], ]); @@ -120,6 +233,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase $page->assertOk() ->assertSee('店铺名') + ->assertSee('预警级别') ->assertSee('违规类型') ->assertSee('违规分类') ->assertSee('违规原因') @@ -127,8 +241,18 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertSee('违规单号') ->assertSee('违规日期') ->assertSee('扣分') + ->assertSee('责任人') ->assertSee('申诉截止日期') + ->assertSee('处理截止') + ->assertSee('是否超时') + ->assertSee('推送状态') ->assertSee('处理状态') + ->assertSee('操作') + ->assertSee('P1-较重') + ->assertSee('张三') + ->assertSee('已超时') + ->assertSee('未推送') + ->assertSee('默认视图') ->assertSee('订单存在延迟发货、缺货无货') ->assertSee('JD202610040001') ->assertSee('京东-亿家达全屋旗舰店') @@ -181,6 +305,7 @@ class StoreViolationAlertPageTest extends FeatureTestCase 'page' => 'store-violation-alert', 'platform' => 'jd', 'keyword' => '卓禾', + 'view' => 'all', ])); $page->assertOk() @@ -189,6 +314,33 @@ class StoreViolationAlertPageTest extends FeatureTestCase ->assertDontSee('JX202610040001'); } + public function test_default_view_only_shows_alert_and_pending_records(): void + { + $this->allowPage(); + + $default = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => 'jd', + ])); + $default->assertOk() + ->assertSee('JD202610040001') + ->assertDontSee('JD202610040002') + ->assertDontSee('JD202610040003'); + + $all = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'store-violation-alert', + 'platform' => 'jd', + 'view' => 'all', + ])); + $all->assertOk() + ->assertSee('JD202610040001') + ->assertSee('JD202610040002') + ->assertSee('JD202610040003') + ->assertSee('不提醒'); + } + public function test_export_streams_unified_headers(): void { $this->allowPage(); @@ -211,15 +363,21 @@ class StoreViolationAlertPageTest extends FeatureTestCase file_put_contents($temp, $binary); $sheet = IOFactory::load($temp)->getActiveSheet(); - $headers = $sheet->rangeToArray('A1:L1')[0]; + $headers = $sheet->rangeToArray('A1:R1')[0]; $this->assertSame( - ['店铺名', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'], + [ + '店铺名', '预警级别', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', + '责任人', '状态', '处罚日期', '申诉截止日期', '处理截止', '是否超时', '推送状态', '处理状态', '忽略原因', + ], $headers, ); - $this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('D2')->getValue()); - $this->assertSame('警告', $sheet->getCell('E2')->getValue()); - $this->assertSame('JD202610040001', $sheet->getCell('F2')->getValue()); + $this->assertSame('P1-较重', $sheet->getCell('B2')->getValue()); + $this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('E2')->getValue()); + $this->assertSame('警告', $sheet->getCell('F2')->getValue()); + $this->assertSame('JD202610040001', $sheet->getCell('G2')->getValue()); + $this->assertSame('张三', $sheet->getCell('J2')->getValue()); + $this->assertSame('已超时', $sheet->getCell('O2')->getValue()); unlink($temp); } @@ -319,6 +477,173 @@ class StoreViolationAlertPageTest extends FeatureTestCase $this->assertSame('警告;整改', $record->disposition); // 未提供违规类型时,违规分类按违规原因关键词自动归类。 $this->assertSame('发货履约', $record->violation_category); + // 预警派生字段按字典自动计算(无级别配置的规则默认 P2)。 + $this->assertTrue((bool) $record->needs_alert); + $this->assertSame('P2', $record->alert_level); + } + + public function test_import_commit_calculates_alert_fields_from_dictionary(): void + { + $this->allowPage(); + + $file = $this->makeWorkbook([ + ['店铺名', '违规单号', '违规类型', '违规日期'], + ['京东-纪辰家具旗舰店', 'JD202610040099', '订单延迟发货', '2026-10-02'], + ]); + + $preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ + 'platform' => 'jd', + 'import' => $file, + ]); + $preview->assertRedirect(); + $payload = $preview->getSession()->get('store_violation_preview'); + + $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [ + 'preview_token' => $payload['token'], + ])->assertRedirect(); + + $record = DB::connection('business_mirror') + ->table('store_violation_records') + ->where('platform', 'jd') + ->where('violation_no', 'JD202610040099') + ->first(); + + $this->assertNotNull($record); + $this->assertTrue((bool) $record->needs_alert); + $this->assertSame('P1', $record->alert_level); + $this->assertSame('王五', $record->owner_name); + // 字典时效 24 小时:违规日期 2026-10-02 起算。 + $this->assertSame('2026-10-03 00:00:00', (string) $record->handling_deadline); + } + + public function test_update_handling_status_requires_reason_when_ignored(): void + { + $this->allowPage(); + $this->withExceptionHandling(); + + $record = DB::connection('business_mirror')->table('store_violation_records') + ->where('violation_no', 'JD202610040001')->first(); + + // 已忽略缺原因 → 报错且状态不变 + $this->loginAs()->post(route('business.store-violation-alert.update'), [ + 'id' => $record->id, + 'handling_status' => '已忽略', + ])->assertSessionHasErrors('ignore_reason'); + $this->assertSame('未处理', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); + + // 认领 → 处理中 + $this->loginAs()->post(route('business.store-violation-alert.update'), [ + 'id' => $record->id, + 'handling_status' => '处理中', + ])->assertRedirect(); + $this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); + + // 忽略(带原因) + $this->loginAs()->post(route('business.store-violation-alert.update'), [ + 'id' => $record->id, + 'handling_status' => '已忽略', + 'ignore_reason' => '误报,无需处理', + ])->assertRedirect(); + $updated = DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->first(); + $this->assertSame('已忽略', $updated->handling_status); + $this->assertSame('误报,无需处理', $updated->ignore_reason); + } + + public function test_claim_route_marks_record_processing_via_signed_link(): void + { + $this->allowPage(); + $this->withExceptionHandling(); + $this->loginAs(); + + $record = DB::connection('business_mirror')->table('store_violation_records') + ->where('violation_no', 'JD202610040001')->first(); + + $url = URL::signedRoute('business.store-violation-alert.claim', [ + 'record' => $record->id, + 'company_id' => $this->company->id, + ]); + $this->get($url)->assertRedirect(); + $this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); + + // 幂等:再次访问不报错。 + $this->get($url)->assertRedirect(); + + // 无签名 → 403。 + $this->get(route('business.store-violation-alert.claim', [ + 'record' => $record->id, + 'company_id' => $this->company->id, + ])) + ->assertStatus(403); + } + + public function test_preview_statistics_page_renders_kpi_cards_and_pending_rows(): void + { + $this->allowPage(); + + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'financial-risk-control', + 'page' => 'preview-statistics', + 'platform' => 'jd', + ])); + + $response->assertOk() + ->assertSeeText('预览统计') + ->assertSeeText('今日新增') + ->assertSeeText('待处理总数') + ->assertSeeText('P0 未处理') + ->assertSeeText('已超时') + ->assertSeeText('待处理 1 条') + ->assertSee('JD202610040001') + ->assertDontSee('JD202610040002') + ->assertDontSee('JD202610040003') + ->assertSee('data-risk-preview-tabs', false); + } + + public function test_push_instant_sends_p0_p1_and_marks_pushed(): void + { + $this->allowPage(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + + $this->artisan('store-violations:push-instant')->assertExitCode(0); + + $record = DB::connection('business_mirror')->table('store_violation_records') + ->where('violation_no', 'JD202610040001')->first(); + $this->assertSame('已推送', $record->push_status); + $this->assertNotNull($record->last_pushed_at); + + $log = DB::connection('business_mirror')->table('store_violation_push_logs') + ->where('message_type', 'instant')->first(); + $this->assertNotNull($log); + $this->assertSame('JD202610040001', $log->violation_no); + $this->assertSame('success', $log->result); + $this->assertSame('张三', $log->mentioned); + + Http::assertSent(function ($request): bool { + $text = (string) ($request['markdown']['text'] ?? ''); + + return str_contains($request->url(), 'oapi.dingtalk.com/robot/send') + && str_contains($text, 'P1-较重') + && str_contains($text, '去认领'); + }); + } + + public function test_push_digest_sends_daily_summary(): void + { + $this->allowPage(); + Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); + + $this->artisan('store-violations:push-digest')->assertExitCode(0); + + $log = DB::connection('business_mirror')->table('store_violation_push_logs') + ->where('message_type', 'daily')->first(); + $this->assertNotNull($log); + $this->assertSame('success', $log->result); + + Http::assertSent(function ($request): bool { + $text = (string) ($request['markdown']['text'] ?? ''); + + return str_contains($text, '每日汇总') && str_contains($text, '已超时未认领'); + }); } /** @param list> $rows */ diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 84cf5d6b..525cca47 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -131,6 +131,7 @@ class NavigationTest extends FeatureTestCase $this->assertSame('security', $navigation[$riskControlIndex]['icon']); $this->assertSame([ ['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'], + ['slug' => 'preview-statistics', 'title' => '预览统计'], [ 'slug' => 'process-statistics', 'title' => '流程统计', diff --git a/tests/Unit/Business/StoreViolationAlertCalculatorTest.php b/tests/Unit/Business/StoreViolationAlertCalculatorTest.php new file mode 100644 index 00000000..965b7750 --- /dev/null +++ b/tests/Unit/Business/StoreViolationAlertCalculatorTest.php @@ -0,0 +1,82 @@ +assertSame('虚假交易', StoreViolationAlertCalculator::cleanViolationType('虚假交易(立即删除销量;)')); + $this->assertSame('AE', StoreViolationAlertCalculator::cleanViolationType('A(B(C)D)E(F)')); + $this->assertSame('订单存在揽收超时行为', StoreViolationAlertCalculator::cleanViolationType(' 订单存在揽收超时行为 ')); + $this->assertSame('', StoreViolationAlertCalculator::cleanViolationType(null)); + } + + public function test_match_rule_uses_exact_prefix_parts_and_keywords_in_order(): void + { + $rules = [ + ['id' => 1, 'platform' => 'jd', 'violation_type' => '缺货/无货', 'match_keywords' => null, + 'alert_level' => 'P1', 'needs_alert' => 1, 'suggested_action' => '联系客户补发', 'handling_hours' => null], + ['id' => 2, 'platform' => 'jingxi', 'violation_type' => '订单延迟发货', 'match_keywords' => null, + 'alert_level' => 'P0', 'needs_alert' => 1, 'suggested_action' => null, 'handling_hours' => null], + ['id' => 3, 'platform' => 'taobao', 'violation_type' => null, 'match_keywords' => '虚假交易|危及交易安全', + 'alert_level' => 'P2', 'needs_alert' => 1, 'suggested_action' => null, 'handling_hours' => null], + ['id' => 4, 'platform' => 'douyin', 'violation_type' => '效果虚假', 'match_keywords' => null, + 'alert_level' => null, 'needs_alert' => 0, 'suggested_action' => null, 'handling_hours' => null], + ]; + + // 分词包含:规则"缺货/无货"命中"延迟发货/缺货无货" + $this->assertSame(1, StoreViolationAlertCalculator::matchRule($rules, 'jd', '延迟发货/缺货无货')['id']); + // 相等与前缀 + $this->assertSame(2, StoreViolationAlertCalculator::matchRule($rules, 'jingxi', '订单延迟发货')['id']); + $this->assertSame(2, StoreViolationAlertCalculator::matchRule($rules, 'jingxi', '订单延迟发货(立即扣分;)')['id']); + // 关键词匹配(先去括号) + $this->assertSame(3, StoreViolationAlertCalculator::matchRule($rules, 'taobao', '虚假交易(立即删除销量&屏蔽评论内容;)')['id']); + // 命中但规则标记为不需要提醒 + $this->assertSame(4, StoreViolationAlertCalculator::matchRule($rules, 'douyin', '效果虚假')['id']); + // 平台隔离与未命中 + $this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'jd', '效果虚假')); + $this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'pinduoduo', '任意类型')); + $this->assertNull(StoreViolationAlertCalculator::matchRule($rules, 'jd', null)); + } + + public function test_deadline_prefers_appeal_date_then_hours_then_level_days(): void + { + // 申诉截止非空 → 当天 23:59:59 + $this->assertSame( + '2026-10-11 23:59:59', + StoreViolationAlertCalculator::deadlineFor('2026-10-04', '2026-10-11', null, 'P1'), + ); + // 字典时效(小时) + $this->assertSame( + '2026-10-04 06:00:00', + StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, 6, 'P0'), + ); + // 级别默认天数:P0+1 / P1+2 / P2+3 天的 23:59:59 + $this->assertSame('2026-10-05 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P0')); + $this->assertSame('2026-10-06 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P1')); + $this->assertSame('2026-10-07 23:59:59', StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, 'P2')); + // 无违规日期或无级别 → null + $this->assertNull(StoreViolationAlertCalculator::deadlineFor(null, null, null, 'P0')); + $this->assertNull(StoreViolationAlertCalculator::deadlineFor('2026-10-04', null, null, null)); + } + + public function test_is_overdue_respects_status_and_deadline(): void + { + $now = Carbon::parse('2026-10-05 10:00:00'); + + $this->assertTrue(StoreViolationAlertCalculator::isOverdue('未处理', '2026-10-04 23:59:59', $now)); + $this->assertTrue(StoreViolationAlertCalculator::isOverdue('处理中', '2026-10-05 09:00:00', $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('处理完成', '2020-01-01 00:00:00', $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('已忽略', '2020-01-01 00:00:00', $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('未处理', null, $now)); + $this->assertFalse(StoreViolationAlertCalculator::isOverdue('未处理', '2026-10-06 23:59:59', $now)); + } +} diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 48c8fbaf..aea94808 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -65,6 +65,12 @@ class BusinessMirrorScheduleTest extends TestCase $script, ); $this->assertSame(1, substr_count($contents, 'store-violations:sync --quiet'), $script); + $this->assertStringContainsString('store-violations:push-instant --quiet', $contents, $script); + $this->assertStringContainsString('store-violations:push-digest --quiet', $contents, $script); + $this->assertStringContainsString('*/5 9-21 * * * root', $contents, $script); + $this->assertStringContainsString('0 10 * * * root', $contents, $script); + $this->assertSame(1, substr_count($contents, 'store-violations:push-instant --quiet'), $script); + $this->assertSame(1, substr_count($contents, 'store-violations:push-digest --quiet'), $script); $this->assertStringContainsString( '15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet', $contents,