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 ? '(已启用筛选条件)' : '' }}。
+
+