CaiWuXiTong/app/Business/StoreViolationAlertCalculator.php

274 lines
9.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}