CaiWuXiTong/app/Console/Commands/SyncStoreViolations.php
苏木 0eb2c844d8 店铺违规预警:预警级别/责任人/处理截止/超时/推送状态 + 处理状态四档 + 群推送接口 + 预览统计页
- 新增违规字典表 store_violation_rules 与推送日志表 store_violation_push_logs;统一表增加预警派生字段(迁移,全部可空带索引)
- 预警计算:按平台+违规类型匹配字典(去括号后 相等/前缀/分词/宽松/关键词),匹配不到=不提醒;责任人取店铺账号镜像运营负责人并回退店铺档案;处理截止=申诉截止优先,否则级别天数(P0+1/P1+2/P2+3,当天23:59:59)
- 页面:新增预警级别(红/橙/绿标签)、责任人、处理截止、是否超时、推送状态5列;级别/责任人(含我的待办)/超时筛选;默认视图(需提醒&未处理)与全部数据切换;行内 认领/完成/忽略(忽略必填原因)
- 处理状态扩为 未处理/处理中/处理完成/已忽略,导入兼容旧值(已处理/无需处理)
- 新增「预览统计」页面:平台Tab联动 + 4张KPI卡(今日新增/待处理/P0未处理/已超时)+ 需提醒未处理预览列表与认领
- 群推送先留接口:P0/P1 每天09:00起每5分钟即时推送(责任人、处理建议、去认领签名链接);P2每日10:00汇总(含超时未认领点名);防重与推送日志;新命令 push-instant/push-digest
- 同步与导入自动计算派生字段;新增 store-violations:recalc-alerts 重算命令;生产 cron 已登记推送任务
- 测试:StoreViolation 系列 + 部署调度 + 导航 共42项/453断言通过
2026-10-13 19:52:44 +08:00

213 lines
7.9 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\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use App\Business\StoreViolationMapper;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
use PDO;
use Throwable;
/**
* 店铺违规信息预警:从经营分析副本库 rpt.crawl_shop_violation 同步到统一表。
*
* 上游只读;按 id 游标分批读取,幂等写入 store_violation_records。
* 本地补充字段(扣分、处罚日期、处理状态)不会被覆盖。
*/
class SyncStoreViolations extends Command
{
protected $signature = 'store-violations:sync
{--platform= : 只同步指定平台(taobao/alibaba/jd/jingxi/pinduoduo/douyin)}
{--batch=1000 : 每批读取行数}';
protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)';
public function handle(
StoreViolationMapper $mapper,
StoreViolationRecordRepository $repository,
StoreViolationAlertCalculator $calculator,
): int
{
if (! $repository->available()) {
$this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。');
return self::FAILURE;
}
$platform = trim((string) $this->option('platform'));
$platforms = (array) config('store_violations.platforms', []);
if ($platform !== '' && ! array_key_exists($platform, $platforms)) {
$this->error('未知平台:' . $platform);
return self::FAILURE;
}
$sourceValues = [];
foreach ($platforms as $slug => $definition) {
if ($platform !== '' && $slug !== $platform) {
continue;
}
foreach ($definition['source_values'] ?? [] as $value) {
$sourceValues[] = (string) $value;
}
}
$sourceValues = array_values(array_unique($sourceValues));
$batchSize = max(min((int) $this->option('batch'), 5000), 100);
try {
$pdo = UpstreamOdbcConnection::create('operations_replica');
} catch (Throwable $exception) {
report($exception);
$repository->recordSyncState('failed', $repository->totalRows(), 'connect_failed');
$this->error('无法连接经营分析副本库:' . $exception->getMessage());
return self::FAILURE;
}
$now = now();
$lastId = 0;
$upserted = 0;
$skipped = 0;
$read = 0;
// 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列,
// 避免每日同步把导入补充的数据清空。
$extraColumns = $this->availableExtraColumns($pdo);
$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) {
// FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"),
// 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。
$selectedColumns = ' id, shop_name, violation_no,'
. ' CONVERT(varchar(10), violation_date, 23) AS violation_date,'
. ' CONVERT(varchar(10), deadline, 23) AS deadline,'
. ' violation_content, status, window_name, platform,'
. ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date';
foreach ($extraColumns as $target => $sourceColumn) {
$selectedColumns .= ', ' . $sourceColumn . ' AS ' . $target;
}
$sql = 'SELECT TOP ' . $batchSize . $selectedColumns
. ' FROM rpt.crawl_shop_violation WHERE id > ?';
$params = [$lastId];
if ($sourceValues !== []) {
$sql .= ' AND platform IN (' . implode(', ', array_fill(0, count($sourceValues), '?')) . ')';
$params = array_merge($params, $sourceValues);
}
$sql .= ' ORDER BY id ASC';
$statement = $pdo->prepare($sql);
$statement->execute($params);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
$statement->closeCursor();
if ($rows === []) {
break;
}
$payload = [];
foreach ($rows as $row) {
$read++;
$lastId = max($lastId, (int) ($row['id'] ?? 0));
$mapped = $mapper->mapCrawlRow($row);
if ($mapped === null || $mapped['violation_no'] === '') {
$skipped++;
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,
'updated_at' => $now,
];
}
$upserted += $repository->upsertRows($payload, $updateColumns);
}
} catch (Throwable $exception) {
report($exception);
$repository->recordSyncState('failed', $repository->totalRows(), 'sync_failed');
$this->error('同步失败:' . $exception->getMessage());
return self::FAILURE;
}
$total = $repository->totalRows();
$repository->recordSyncState('succeeded', $total);
$this->info(sprintf(
'同步完成:读取 %d 行,写入/更新 %d 行,跳过 %d 行,统一表共 %d 行。',
$read,
$upserted,
$skipped,
$total,
));
return self::SUCCESS;
}
/** @return array<string,string> 统一表字段 => 上游视图列名 */
private function availableExtraColumns(PDO $pdo): array
{
try {
$statement = $pdo->query(
"SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('rpt.crawl_shop_violation')"
);
$names = $statement->fetchAll(PDO::FETCH_COLUMN);
$statement->closeCursor();
} catch (Throwable) {
return [];
}
return self::pickExtraColumns(array_map('strval', $names));
}
/**
* 从上游可用列名中挑选可同步的扩展列;列名以数据平台实际提供的为准。
*
* @param list<string> $availableColumns
* @return array<string,string>
*/
public static function pickExtraColumns(array $availableColumns): array
{
$candidates = [
'violation_reason' => ['violation_reason', 'reason'],
'disposition' => ['disposition', 'handling_method', 'disposal'],
];
$available = array_map(static fn (string $name): string => strtolower($name), $availableColumns);
$selected = [];
foreach ($candidates as $target => $names) {
foreach ($names as $name) {
if (in_array($name, $available, true)) {
$selected[$target] = $name;
break;
}
}
}
return $selected;
}
}