274 lines
9.2 KiB
PHP
274 lines
9.2 KiB
PHP
<?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);
|
||
}
|
||
}
|