店铺违规预警:级别/责任人/处理截止/超时/推送状态 + 处理状态四档 + 群推送接口 + 预览统计页 (#332)
This commit is contained in:
parent
530c9858ec
commit
ea80148dfc
273
app/Business/StoreViolationAlertCalculator.php
Normal file
273
app/Business/StoreViolationAlertCalculator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
237
app/Business/StoreViolationNotifier.php
Normal file
237
app/Business/StoreViolationNotifier.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
41
app/Console/Commands/PushStoreViolationAlerts.php
Normal file
41
app/Console/Commands/PushStoreViolationAlerts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/PushStoreViolationDigest.php
Normal file
38
app/Console/Commands/PushStoreViolationDigest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
app/Console/Commands/RecalcStoreViolationAlerts.php
Normal file
73
app/Console/Commands/RecalcStoreViolationAlerts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', [
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -871,6 +871,7 @@ $navigation = [
|
||||
'icon' => 'security',
|
||||
'pages' => [
|
||||
['slug' => 'store-violation-alert', 'title' => '店铺违规信息预警'],
|
||||
['slug' => 'preview-statistics', 'title' => '预览统计'],
|
||||
[
|
||||
'slug' => 'process-statistics',
|
||||
'title' => '流程统计',
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一表头导出用的行数据(流式读取,避免一次性载入)。
|
||||
*
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
@ -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')
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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' => '流程统计',
|
||||
|
||||
82
tests/Unit/Business/StoreViolationAlertCalculatorTest.php
Normal file
82
tests/Unit/Business/StoreViolationAlertCalculatorTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user