店铺违规预警:级别/责任人/处理截止/超时/推送状态 + 处理状态四档 + 群推送接口 + 预览统计页 (#332)

This commit is contained in:
renshen 2026-10-13 19:54:01 +08:00
parent 530c9858ec
commit ea80148dfc
27 changed files with 2574 additions and 39 deletions

View File

@ -0,0 +1,273 @@
<?php
namespace App\Business;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use Throwable;
/**
* 店铺违规信息预警:预警级别、责任人、处理截止等派生字段的计算。
*
* - 级别/是否提醒:按「平台 + 违规类型」匹配违规字典;原文先去(…)括号处置说明,
* 再做 相等/前缀/关键词 匹配;匹配不到 = 不需要提醒(只入库不推送)。
* - 责任人:店铺账号镜像的运营负责人(与店铺维度报表同口径),
* 未命中时回退店铺档案(ops_fys_shop_info.shopHead)。
* - 处理截止:申诉截止日期非空→当天 23:59:59;否则按字典时效(小时)或
* 级别默认天数(P0+1 / P1+2 / P2+3 天)的当天 23:59:59。
*/
class StoreViolationAlertCalculator
{
/** @var list<array<string,mixed>>|null */
private ?array $rules = null;
/** @var array<string,array{operation_owner:string,operation_director:string,profit_center:string}>|null */
private ?array $ownerMap = null;
/** @var array<string,?string> */
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<string,mixed> 预警相关字段(用于同步/导入写入) */
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<array<string,mixed>> */
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<array<string,mixed>> $rules 已按优先级排序
* @return array<string,mixed>|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);
}
}

View File

@ -0,0 +1,237 @@
<?php
namespace App\Business;
use App\Models\Common\Company;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationPushRepository;
use RuntimeException;
use Throwable;
/**
* 店铺违规信息预警:内部群机器人推送(先留接口,配置 Webhook 后启用)。
*
* - P0/P1 即时推送(每天 09:00 起由定时任务检查),消息含去认领签名链接;
* - P2 不即时推,每日 10:00 汇总成一条日报;
* - 以违规记录为唯一对象:已推送的不再重复推送(失败可显式重试)。
*/
class StoreViolationNotifier
{
public function __construct(private readonly StoreViolationPushRepository $pushes)
{
}
public function enabled(): bool
{
$config = (array) config('store_violations.push', []);
return (bool) ($config['enabled'] ?? false)
&& trim((string) ($config['webhook'] ?? '')) !== '';
}
/**
* @return array{attempted:int,succeeded:int,failed:int}
*/
public function pushInstant(int $limit = 200, bool $retryFailed = false): array
{
$levels = array_values(array_map('strval', (array) config('store_violations.push.instant_levels', ['P0', 'P1'])));
$candidates = $this->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<string,int>,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<string> $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));
}
}

View File

@ -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',

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P0/P1 即时推送(每天 09:00 起由生产 cron 每 5 分钟触发)。
*/
class PushStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:push-instant
{--limit=200 : 单轮最多推送条数}
{--retry-failed : 同时重试推送失败的记录}';
protected $description = '推送店铺违规预警即时消息(P0/P1,含责任人@与去认领链接)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->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;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P2 每日 10:00 汇总日报。
*/
class PushStoreViolationDigest extends Command
{
protected $signature = 'store-violations:push-digest';
protected $description = '推送店铺违规预警每日汇总(P2 汇总与超时未认领点名)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->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;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
/**
* 店铺违规信息预警:按当前字典/归属重新计算全部记录的预警派生字段。
*
* 用于字典维护或店铺归属调整后的校准;不触碰处理状态、忽略原因与推送状态。
*/
class RecalcStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:recalc-alerts
{--platform= : 只重算指定平台}
{--chunk=500 : 每批更新行数}';
protected $description = '重算店铺违规记录的预警级别、责任人与处理截止';
public function handle(StoreViolationAlertCalculator $calculator, StoreViolationRecordRepository $repository): int
{
if (! $repository->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;
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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<string,string> */
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', [

View File

@ -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);

View File

@ -871,6 +871,7 @@ $navigation = [
'icon' => 'security',
'pages' => [
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
['slug' => 'preview-statistics', 'title' => '预览统计'],
[
'slug' => 'process-statistics',
'title' => '流程统计',

View File

@ -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'],

View File

@ -0,0 +1,104 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:预警工作流字段(级别/责任人/处理截止/忽略/推送状态)。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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);
});
}
}
}
};

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:违规字典(预警级别与提醒规则,支持后台维护)。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:推送日志(即时提醒与每日汇总)。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,128 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Connection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* 店铺违规信息预警:推送取数、状态回写与推送日志(business_mirror)。
*/
class StoreViolationPushRepository
{
public const LOG_TABLE = 'store_violation_push_logs';
private Connection $connection;
public function __construct()
{
$this->connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
/**
* 即时推送候选:需提醒 + 未处理 + 未推送 + 指定级别。
*
* @param list<string> $levels
* @return Collection<int,object>
*/
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<string,int>,overdue:Collection<int,object>,p2_pending:Collection<int,object>}
*/
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<int> $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<string,mixed> $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);
}
}

View File

@ -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<string,mixed> $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<string>,handling_statuses:list<string>,categories:list<string>,shops:list<string>} */
/** @return array{statuses:list<string>,handling_statuses:list<string>,categories:list<string>,shops:list<string>,owners:list<string>,levels:list<string>} */
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<string,array{new_today:int,pending:int,p0_pending:int,overdue:int}>
*/
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;
}
/**
* 统一表头导出用的行数据(流式读取,避免一次性载入)。
*

View File

@ -0,0 +1,58 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 店铺违规信息预警:违规字典(store_violation_rules,business_mirror)。
*/
class StoreViolationRuleRepository
{
public const TABLE = 'store_violation_rules';
private Connection $connection;
public function __construct()
{
$this->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<array<string,mixed>> 启用中的规则,按 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<array<string,mixed>> 全部规则(后台维护用),按 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();
}
}

View File

@ -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 ? '(已启用筛选条件)' : '' }}。
</p>
<div class="financial-risk-control__viewbar">
<span class="financial-risk-control__viewbar-label">数据范围</span>
<a class="upstream-data-button {{ $defaultView ? 'is-active' : '' }}" href="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => $slug, 'view' => 'default']) }}">默认视图(需提醒 · 未处理)</a>
<a class="upstream-data-button {{ $defaultView ? '' : 'is-active' }}" href="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => $slug, 'view' => 'all']) }}">全部数据</a>
</div>
<form class="upstream-data-toolbar" method="get" action="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert']) }}" aria-label="{{ $label }}店铺违规信息筛选">
<input type="hidden" name="platform" value="{{ $slug }}">
<input type="hidden" name="view" value="{{ $defaultView ? 'default' : 'all' }}">
<div class="upstream-data-field">
<label for="store-violation-keyword-{{ $slug }}">关键词</label>
<input id="store-violation-keyword-{{ $slug }}" name="keyword" type="text" value="{{ $filters['keyword'] ?? '' }}" placeholder="店铺名 / 违规单号 / 违规类型">
@ -473,6 +566,32 @@
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-level-{{ $slug }}">预警级别</label>
<select id="store-violation-level-{{ $slug }}" name="level">
<option value="">全部级别</option>
@foreach (($filterOptions['levels'] ?? []) as $levelOption)
<option value="{{ $levelOption }}" @selected(($filters['level'] ?? '') === $levelOption)>{{ $alertLevels[$levelOption]['label'] ?? $levelOption }}</option>
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-owner-{{ $slug }}">责任人</label>
<select id="store-violation-owner-{{ $slug }}" name="owner">
<option value="">全部责任人</option>
<option value="me" @selected(($filters['owner_is_me'] ?? '') === '1')>我的待办</option>
@foreach (($filterOptions['owners'] ?? []) as $ownerOption)
<option value="{{ $ownerOption }}" @selected(($filters['owner_is_me'] ?? '') !== '1' && ($filters['owner'] ?? '') === $ownerOption)>{{ $ownerOption }}</option>
@endforeach
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-overdue-{{ $slug }}">是否超时</label>
<select id="store-violation-overdue-{{ $slug }}" name="overdue">
<option value="">全部</option>
<option value="1" @selected(($filters['overdue'] ?? '') === '1')>只看已超时</option>
</select>
</div>
<div class="upstream-data-field">
<label for="store-violation-category-{{ $slug }}">违规分类</label>
<select id="store-violation-category-{{ $slug }}" name="category">
@ -541,6 +660,7 @@
<tr>
<th scope="col">序号</th>
<th scope="col">店铺名</th>
<th scope="col">预警级别</th>
<th scope="col">违规类型</th>
<th scope="col">违规分类</th>
<th scope="col">违规原因</th>
@ -548,17 +668,32 @@
<th scope="col">违规单号</th>
<th scope="col">违规日期</th>
<th scope="col">扣分</th>
<th scope="col">责任人</th>
<th scope="col">状态</th>
<th scope="col">处罚日期</th>
<th scope="col">申诉截止日期</th>
<th scope="col">处理截止</th>
<th scope="col">是否超时</th>
<th scope="col">推送状态</th>
<th scope="col">处理状态</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse (($rows ?? collect()) as $index => $record)
@php
$overdue = \App\Business\StoreViolationAlertCalculator::isOverdue($record->handling_status ?? null, $record->handling_deadline ?? null);
@endphp
<tr>
<td style="color: var(--vip-content-muted, #595959)">{{ ($rows->firstItem() ?? 1) + $index }}</td>
<td>{{ $record->shop_name ?: '-' }}</td>
<td>
@if (! empty($record->alert_level) && ($record->needs_alert ?? false))
<span class="financial-risk-control__chip {{ $levelTone($record->alert_level) }}">{{ $levelLabel($record->alert_level) }}</span>
@else
<span class="financial-risk-control__chip is-muted">不提醒</span>
@endif
</td>
<td title="{{ $record->violation_type }}">{{ \Illuminate\Support\Str::limit((string) $record->violation_type, 40, '…') ?: '-' }}</td>
<td>{{ $record->violation_category ?: '-' }}</td>
<td title="{{ $record->violation_reason }}">{{ \Illuminate\Support\Str::limit((string) $record->violation_reason, 40, '…') ?: '-' }}</td>
@ -566,18 +701,48 @@
<td>{{ $record->violation_no ?: '-' }}</td>
<td>{{ $record->violation_date ?: '-' }}</td>
<td class="is-number">{{ $record->points_deducted === null ? '-' : rtrim(rtrim(number_format((float) $record->points_deducted, 2, '.', ''), '0'), '.') }}</td>
<td>{{ $record->owner_name ?: '-' }}</td>
<td>{{ $record->status ?: '-' }}</td>
<td>{{ $record->penalty_date ?: '-' }}</td>
<td>{{ $record->appeal_deadline ?: '-' }}</td>
<td title="{{ $record->handling_deadline }}">{{ $record->handling_deadline ? \Illuminate\Support\Carbon::parse($record->handling_deadline)->format('Y-m-d H:i') : '-' }}</td>
<td>
@if ($overdue)
<span class="financial-risk-control__chip is-warning">已超时</span>
@else
<span class="financial-risk-control__chip is-muted">未超时</span>
@endif
</td>
<td>
<span class="financial-risk-control__chip {{ $pushTone($record->push_status ?? '未推送') }}">{{ $record->push_status ?: '未推送' }}</span>
</td>
<td>
<span class="financial-risk-control__chip {{ $handlingTone((string) $record->handling_status) }}">
{{ $record->handling_status ?: '未处理' }}
</span>
@if (($record->handling_status ?? '') === '已忽略' && ! empty($record->ignore_reason))
<div class="financial-risk-control__muted-note" title="{{ $record->ignore_reason }}">{{ \Illuminate\Support\Str::limit((string) $record->ignore_reason, 14, '…') }}</div>
@endif
</td>
<td>
<form method="post" action="{{ route('business.store-violation-alert.update') }}" class="financial-risk-control__row-actions">
@csrf
<input type="hidden" name="id" value="{{ $record->id }}">
<button type="submit" name="handling_status" value="处理中" title="认领(处理中)">认领</button>
<button type="submit" name="handling_status" value="处理完成" title="标记处理完成">完成</button>
<details class="financial-risk-control__ignore">
<summary title="忽略(需填写原因)">忽略</summary>
<div class="financial-risk-control__ignore-body">
<input type="text" name="ignore_reason" maxlength="191" placeholder="忽略原因(必填)">
<button type="submit" name="handling_status" value="已忽略">确认</button>
</div>
</details>
</form>
</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="13">
<td colspan="19">
@if ($error)
{{ $error }}
@elseif ($platformTotal === 0)

View File

@ -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
<style>
.financial-risk-control {
margin-top: 1.5rem;
}
.financial-risk-control__tabs {
display: flex;
gap: 0.25rem;
overflow-x: auto;
padding: 0.25rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #FFFFFF);
scrollbar-width: thin;
scrollbar-color: var(--vip-content-accent, #B9954F) transparent;
}
.financial-risk-control__tabs::-webkit-scrollbar {
height: 0.375rem;
}
.financial-risk-control__tabs::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--vip-content-accent, #B9954F);
}
.financial-risk-control__tab {
display: inline-flex;
min-width: 7.5rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0 1rem;
border: 0;
border-bottom: 0.1875rem solid transparent;
border-radius: 0.5rem 0.5rem 0 0;
background: transparent;
color: var(--vip-content-muted, #595959);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.financial-risk-control__tab:hover,
.financial-risk-control__tab:focus-visible {
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
outline: none;
}
.financial-risk-control__tab.is-active {
border-bottom-color: var(--vip-content-accent, #B9954F);
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
}
.financial-risk-control__tab .material-icons-outlined {
font-size: 1.125rem;
}
.financial-risk-control__tab-count {
padding: 0 0.375rem;
border-radius: 999px;
background: rgba(185, 149, 79, 0.18);
color: var(--vip-content-accent-dark, #856632);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.25rem;
}
.financial-risk-control__panel {
min-height: 22rem;
margin-top: 1rem;
padding: 1.5rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #FFFFFF);
color: var(--vip-content-ink, #171717);
}
.financial-risk-control__panel[hidden] {
display: none;
}
.financial-risk-control__panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--vip-border, #D8D2C7);
}
.financial-risk-control__panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--vip-content-ink, #171717);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
}
.financial-risk-control__panel-title .material-icons-outlined {
color: var(--vip-content-accent-dark, #856632);
font-size: 1.25rem;
}
.financial-risk-control__panel-description {
max-width: 36rem;
margin: 0.5rem 0 0;
color: var(--vip-content-muted, #595959);
font-size: 0.8125rem;
line-height: 1.5;
}
.financial-risk-control__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 999px;
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
font-size: 0.75rem;
line-height: 1.333;
white-space: nowrap;
}
.risk-preview__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
margin: 0 0 1rem;
}
.risk-preview__card {
padding: 0.75rem 1rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 0.75rem;
background: var(--vip-surface, #FFFFFF);
}
.risk-preview__card-label {
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
}
.risk-preview__card-value {
margin-top: 0.25rem;
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.risk-preview__card.is-danger .risk-preview__card-value {
color: var(--vip-danger, #B80000);
}
.risk-preview__card.is-warning .risk-preview__card-value {
color: var(--vip-warning, #935F07);
}
.risk-preview__table {
min-width: 64rem;
}
.risk-preview__chip {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.75rem;
line-height: 1.333;
white-space: nowrap;
}
.risk-preview__chip.is-danger {
border-color: rgba(184, 0, 0, 0.35);
background: rgba(184, 0, 0, 0.08);
color: var(--vip-danger, #B80000);
}
.risk-preview__chip.is-warning {
border-color: rgba(147, 95, 7, 0.35);
background: rgba(147, 95, 7, 0.12);
color: var(--vip-warning, #935F07);
}
.risk-preview__chip.is-success {
border-color: rgba(83, 121, 62, 0.35);
background: rgba(83, 121, 62, 0.12);
color: var(--vip-success, #53793E);
}
.risk-preview__chip.is-muted {
border-color: var(--vip-border, #D8D2C7);
background: #F4F2ED;
color: var(--vip-content-muted, #595959);
}
.risk-preview__actions {
display: flex;
align-items: center;
gap: 0.375rem;
}
.risk-preview__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;
}
.risk-preview__actions button:hover {
border-color: var(--vip-content-accent, #B9954F);
color: var(--vip-content-accent-dark, #856632);
}
</style>
<section
class="financial-risk-control upstream-data-page"
data-risk-preview-tabs
data-active-platform="{{ $activePlatform }}"
aria-label="店铺违规预警预览统计"
>
@include('business.pages.partials.upstream-data-styles')
<nav class="financial-risk-control__tabs" role="tablist" aria-label="平台">
@foreach ($platforms as $slug => $platform)
@php $count = $platformCounts[$slug] ?? 0; @endphp
<button
type="button"
class="financial-risk-control__tab {{ $activePlatform === $slug ? 'is-active' : '' }}"
id="risk-preview-tab-{{ $slug }}"
role="tab"
aria-controls="risk-preview-panel-{{ $slug }}"
aria-selected="{{ $activePlatform === $slug ? 'true' : 'false' }}"
data-platform-tab="{{ $slug }}"
>
<span class="material-icons-outlined" aria-hidden="true">{{ $platform['icon'] ?? 'storefront' }}</span>
<span>{{ $platform['label'] ?? $slug }}</span>
@if ($count > 0)
<span class="financial-risk-control__tab-count">{{ number_format($count) }}</span>
@endif
</button>
@endforeach
</nav>
@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
<section
class="financial-risk-control__panel"
id="risk-preview-panel-{{ $slug }}"
role="tabpanel"
aria-labelledby="risk-preview-tab-{{ $slug }}"
data-platform-panel="{{ $slug }}"
@if ($activePlatform !== $slug) hidden @endif
>
<header class="financial-risk-control__panel-header">
<div>
<h2 class="financial-risk-control__panel-title">
<span class="material-icons-outlined" aria-hidden="true">{{ $platform['icon'] ?? 'storefront' }}</span>
<span>{{ $label }}预警预览统计</span>
</h2>
<p class="financial-risk-control__panel-description">需要提醒且未处理的违规记录概览;完整查询与处理请前往「店铺违规信息预警」。</p>
</div>
<span class="financial-risk-control__status">
<span class="material-icons-outlined" aria-hidden="true">insights</span>
<span>待处理 {{ number_format($stats['pending']) }} 条</span>
</span>
</header>
@if ($error)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<div class="risk-preview__kpis" aria-label="{{ $label }}预警统计">
<div class="risk-preview__card">
<div class="risk-preview__card-label">今日新增</div>
<div class="risk-preview__card-value">{{ number_format($stats['new_today']) }}</div>
</div>
<div class="risk-preview__card">
<div class="risk-preview__card-label">待处理总数</div>
<div class="risk-preview__card-value">{{ number_format($stats['pending']) }}</div>
</div>
<div class="risk-preview__card is-danger">
<div class="risk-preview__card-label">P0 未处理</div>
<div class="risk-preview__card-value">{{ number_format($stats['p0_pending']) }}</div>
</div>
<div class="risk-preview__card is-warning">
<div class="risk-preview__card-label">已超时</div>
<div class="risk-preview__card-value">{{ number_format($stats['overdue']) }}</div>
</div>
</div>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table risk-preview__table" aria-label="{{ $label }}预警预览">
<thead>
<tr>
<th scope="col">序号</th>
<th scope="col">预警级别</th>
<th scope="col">店铺名</th>
<th scope="col">违规类型</th>
<th scope="col">责任人</th>
<th scope="col">处理截止</th>
<th scope="col">是否超时</th>
<th scope="col">推送状态</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse (($rows ?? collect()) as $index => $record)
@php
$overdue = \App\Business\StoreViolationAlertCalculator::isOverdue($record->handling_status ?? null, $record->handling_deadline ?? null);
@endphp
<tr>
<td style="color: var(--vip-content-muted, #595959)">{{ ($rows->firstItem() ?? 1) + $index }}</td>
<td>
<span class="risk-preview__chip {{ $levelTone($record->alert_level) }}">{{ $levelLabel($record->alert_level) }}</span>
</td>
<td>{{ $record->shop_name ?: '-' }}</td>
<td title="{{ $record->violation_type }}">{{ \Illuminate\Support\Str::limit((string) $record->violation_type, 30, '…') ?: '-' }}</td>
<td>{{ $record->owner_name ?: '-' }}</td>
<td title="{{ $record->handling_deadline }}">{{ $record->handling_deadline ? \Illuminate\Support\Carbon::parse($record->handling_deadline)->format('Y-m-d H:i') : '-' }}</td>
<td>
@if ($overdue)
<span class="risk-preview__chip is-warning">已超时</span>
@else
<span class="risk-preview__chip is-muted">未超时</span>
@endif
</td>
<td>
<span class="risk-preview__chip {{ $pushTone($record->push_status ?? '未推送') }}">{{ $record->push_status ?: '未推送' }}</span>
</td>
<td>
<div class="risk-preview__actions">
<form method="post" action="{{ route('business.store-violation-alert.update') }}">
@csrf
<input type="hidden" name="id" value="{{ $record->id }}">
<button type="submit" name="handling_status" value="处理中" title="认领(处理中)">认领</button>
</form>
<a class="upstream-data-button" href="{{ route('business.pages.show', ['section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => $slug, 'view' => 'default', 'keyword' => $record->violation_no]) }}">去处理</a>
</div>
</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="9">
@if ($error)
{{ $error }}
@else
暂无需要提醒且未处理的记录。
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows instanceof \Illuminate\Pagination\LengthAwarePaginator && $rows->total() > 0)
<div class="upstream-data-pagination">
<span>
共 {{ number_format($rows->total()) }} 行
· 每页 {{ $rows->perPage() }} 行
· 第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页
</span>
<div class="upstream-data-pagination__controls" aria-label="{{ $label }}预警预览分页">
@if ($rows->onFirstPage())
<span class="upstream-data-button is-disabled" aria-disabled="true" title="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</span>
@else
<a class="upstream-data-button" href="{{ $rows->previousPageUrl() }}" aria-label="上一页" title="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</a>
@endif
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
@if ($rows->hasMorePages())
<a class="upstream-data-button" href="{{ $rows->nextPageUrl() }}" aria-label="下一页" title="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</a>
@else
<span class="upstream-data-button is-disabled" aria-disabled="true" title="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</span>
@endif
</div>
</div>
@endif
</div>
</section>
@endforeach
</section>
<script>
(() => {
const root = document.querySelector('[data-risk-preview-tabs]');
if (!root || root.dataset.bound === 'true') return;
root.dataset.bound = 'true';
const tabs = [...root.querySelectorAll('[data-platform-tab]')];
const panels = [...root.querySelectorAll('[data-platform-panel]')];
const validPlatforms = new Set(tabs.map((tab) => tab.dataset.platformTab));
const setPlatform = (platform, updateUrl = true) => {
const active = validPlatforms.has(platform) ? platform : tabs[0]?.dataset.platformTab;
if (!active) return;
root.dataset.activePlatform = active;
tabs.forEach((tab) => {
const selected = tab.dataset.platformTab === active;
tab.classList.toggle('is-active', selected);
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.platformPanel !== active;
});
if (updateUrl) {
const url = new URL(window.location.href);
url.searchParams.set('platform', active);
url.searchParams.delete('page');
window.history.pushState({ platform: active }, '', url);
}
};
tabs.forEach((tab) => tab.addEventListener('click', () => setPlatform(tab.dataset.platformTab)));
window.addEventListener('popstate', () => setPlatform(new URL(window.location.href).searchParams.get('platform'), false));
})();
</script>

View File

@ -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')

View File

@ -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<list<mixed>> $rows */

View File

@ -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' => '流程统计',

View File

@ -0,0 +1,82 @@
<?php
namespace Tests\Unit\Business;
use App\Business\StoreViolationAlertCalculator;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* 店铺违规信息预警:预警级别匹配、处理截止与超时判定。
*/
class StoreViolationAlertCalculatorTest extends TestCase
{
public function test_clean_violation_type_strips_parenthesized_disposition(): void
{
$this->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));
}
}

View File

@ -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,