店铺违规预警:新增违规原因与处置方式两列(页面/统一表/导入导出) (#323)

This commit is contained in:
renshen 2026-10-13 11:32:27 +08:00
parent ac40c6d8a9
commit d47a72c175
10 changed files with 264 additions and 19 deletions

View File

@ -72,6 +72,9 @@ class StoreViolationMapper
'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191),
'violation_type' => $content === '' ? null : mb_substr($content, 0, 500),
'violation_category' => $this->categoryForContent($content),
// 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。
'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500),
'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500),
'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100),
'violation_date' => $this->normalizeDate($row['violation_date'] ?? null),
'status' => $this->normalizeLabel($row['status'] ?? null, 64),

View File

@ -305,13 +305,25 @@ class StoreViolationWorkbookImporter
$data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500);
}
$reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null;
if ($has('violation_reason')) {
$data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500);
}
if ($has('disposition')) {
$disposition = trim((string) ($raw['disposition'] ?? ''));
$data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500);
}
if ($has('violation_category')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$category = trim((string) ($raw['violation_category'] ?? ''));
$data['violation_category'] = $category === ''
? $this->mapper->categoryForContent($type)
? $this->mapper->categoryForContent($categorySource)
: mb_substr($category, 0, 64);
} elseif ($has('violation_type')) {
$data['violation_category'] = $this->mapper->categoryForContent($type);
} elseif ($has('violation_type') || $has('violation_reason')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$data['violation_category'] = $this->mapper->categoryForContent($categorySource);
}
if ($has('violation_date')) {

View File

@ -68,16 +68,27 @@ class SyncStoreViolations extends Command
$skipped = 0;
$read = 0;
// 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列,
// 避免每日同步把导入补充的数据清空。
$extraColumns = $this->availableExtraColumns($pdo);
$updateColumns = $extraColumns === []
? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS
: array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns));
try {
while (true) {
// FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"),
// 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。
$sql = 'SELECT TOP ' . $batchSize
. ' id, shop_name, violation_no,'
$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'
. ' 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];
@ -114,7 +125,7 @@ class SyncStoreViolations extends Command
];
}
$upserted += $repository->upsertRows($payload, StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS);
$upserted += $repository->upsertRows($payload, $updateColumns);
}
} catch (Throwable $exception) {
report($exception);
@ -137,4 +148,47 @@ class SyncStoreViolations extends Command
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;
}
}

View File

@ -3231,7 +3231,7 @@ class Pages extends Controller
$rowNumber++;
}
foreach (['A' => 30, 'B' => 36, 'C' => 14, 'D' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) {
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) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
@ -3257,7 +3257,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', '未处理'],
['示例·某某旗舰店', '延迟发货/缺货无货', '发货履约', '订单存在揽收超时行为', '警告', 'JD202610040001', '2026-10-04', 2, '待处理', '', '2026-10-11', '未处理'],
null,
'A2',
);
@ -3268,7 +3268,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' => 24, 'E' => 14, 'F' => 10, 'G' => 16, 'H' => 14, 'I' => 16, 'J' => 12] as $column => $width) {
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) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
@ -3281,6 +3281,8 @@ class Pages extends Controller
['违规单号', '必填。同一平台内唯一;已存在的单号会按文件内容更新(导入预览会逐行提示)。'],
['违规类型', '选填。平台显示的违规内容/类型,例如“延迟发货/缺货无货”。'],
['违规分类', '选填。留空时按违规类型关键词自动归类。'],
['违规原因', '选填。平台展示的违规原因/详细说明。'],
['处置方式', '选填。平台对违规的处置/处理方式。'],
['违规日期 / 处罚日期 / 申诉截止日期', '选填。格式 YYYY-MM-DD,例如 2026-10-04。'],
['扣分', '选填。数字,例如 2 或 0.5。'],
['状态', '选填。平台状态,例如“待处理”“超期未申诉”。'],
@ -3294,7 +3296,7 @@ class Pages extends Controller
]);
$notes->getColumnDimension('A')->setWidth(34);
$notes->getColumnDimension('B')->setWidth(72);
$notes->getStyle('B1:B11')->getAlignment()->setWrapText(true);
$notes->getStyle('B1:B13')->getAlignment()->setWrapText(true);
(new Xlsx($spreadsheet))->save('php://output');
}, '店铺违规信息导入模板_' . now()->format('Ymd') . '.xlsx', [

View File

@ -16,6 +16,8 @@ return [
['key' => 'shop_name', 'label' => '店铺名'],
['key' => 'violation_type', 'label' => '违规类型'],
['key' => 'violation_category', 'label' => '违规分类'],
['key' => 'violation_reason', 'label' => '违规原因'],
['key' => 'disposition', 'label' => '处置方式'],
['key' => 'violation_no', 'label' => '违规单号'],
['key' => 'violation_date', 'label' => '违规日期'],
['key' => 'points_deducted', 'label' => '扣分'],
@ -42,8 +44,10 @@ return [
// 导入表头别名 → 统一列键(不区分大小写、忽略空格)
'header_aliases' => [
'shop_name' => ['店铺名', '店铺名称', '店铺', '商家名称', '商家', 'shop_name', 'shopname'],
'violation_type' => ['违规类型', '违规内容', '违规行为', '违规原因', '违规描述', 'violation_type'],
'violation_type' => ['违规类型', '违规内容', '违规行为', '违规描述', 'violation_type'],
'violation_category' => ['违规分类', '违规类别', '分类', 'violation_category'],
'violation_reason' => ['违规原因', '违规说明', '违规详情', 'violation_reason', 'reason'],
'disposition' => ['处置方式', '处置措施', '处置结果', '处理方式', 'disposition', 'handling_method'],
'violation_no' => ['违规单号', '违规编号', '违规id', '违规ID', '单号', 'violation_no'],
'violation_date' => ['违规日期', '违规时间', '违规发生时间', 'violation_date'],
'points_deducted' => ['扣分', '扣分数', '扣除分数', '罚分', 'points_deducted'],

View File

@ -0,0 +1,59 @@
<?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_records')) {
return;
}
$missing = [];
foreach (['violation_reason', 'disposition'] as $column) {
if (! $schema->hasColumn('store_violation_records', $column)) {
$missing[] = $column;
}
}
if ($missing === []) {
return;
}
$schema->table('store_violation_records', function (Blueprint $table) use ($missing): void {
if (in_array('violation_reason', $missing, true)) {
$table->string('violation_reason', 500)->nullable()->after('violation_category');
}
if (in_array('disposition', $missing, true)) {
$table->string('disposition', 500)->nullable()->after('violation_reason');
}
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_violation_records')) {
return;
}
foreach (['disposition', 'violation_reason'] as $column) {
if ($schema->hasColumn('store_violation_records', $column)) {
$schema->table('store_violation_records', function (Blueprint $table) use ($column): void {
$table->dropColumn($column);
});
}
}
}
};

View File

@ -543,6 +543,8 @@
<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>
@ -559,6 +561,8 @@
<td>{{ $record->shop_name ?: '-' }}</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>
<td title="{{ $record->disposition }}">{{ \Illuminate\Support\Str::limit((string) $record->disposition, 30, '…') ?: '-' }}</td>
<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>
@ -573,7 +577,7 @@
</tr>
@empty
<tr class="is-empty">
<td colspan="11">
<td colspan="13">
@if ($error)
{{ $error }}
@elseif ($platformTotal === 0)

View File

@ -37,6 +37,8 @@ class StoreViolationAlertPageTest extends FeatureTestCase
$table->string('shop_name', 191)->default('');
$table->string('violation_type', 500)->nullable();
$table->string('violation_category', 64)->nullable();
$table->string('violation_reason', 500)->nullable();
$table->string('disposition', 500)->nullable();
$table->string('violation_no', 100);
$table->date('violation_date')->nullable();
$table->decimal('points_deducted', 10, 2)->nullable();
@ -69,21 +71,24 @@ class StoreViolationAlertPageTest extends FeatureTestCase
DB::connection('business_mirror')->table('store_violation_records')->insert([
[
'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'violation_type' => '延迟发货/缺货无货',
'violation_category' => '发货履约', 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04',
'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',
'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_no' => 'JD202610040002', 'violation_date' => '2026-10-03',
'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',
'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_no' => 'JX202610040001', 'violation_date' => '2026-10-04',
'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',
'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00',
@ -117,11 +122,14 @@ class StoreViolationAlertPageTest extends FeatureTestCase
->assertSee('店铺名')
->assertSee('违规类型')
->assertSee('违规分类')
->assertSee('违规原因')
->assertSee('处置方式')
->assertSee('违规单号')
->assertSee('违规日期')
->assertSee('扣分')
->assertSee('申诉截止日期')
->assertSee('处理状态')
->assertSee('订单存在延迟发货、缺货无货')
->assertSee('JD202610040001')
->assertSee('京东-亿家达全屋旗舰店')
->assertSee('data-platform-panel="jingxi"', false);
@ -166,13 +174,15 @@ class StoreViolationAlertPageTest extends FeatureTestCase
file_put_contents($temp, $binary);
$sheet = IOFactory::load($temp)->getActiveSheet();
$headers = $sheet->rangeToArray('A1:J1')[0];
$headers = $sheet->rangeToArray('A1:L1')[0];
$this->assertSame(
['店铺名', '违规类型', '违规分类', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'],
['店铺名', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', '状态', '处罚日期', '申诉截止日期', '处理状态'],
$headers,
);
$this->assertSame('JD202610040001', $sheet->getCell('D2')->getValue());
$this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('D2')->getValue());
$this->assertSame('警告', $sheet->getCell('E2')->getValue());
$this->assertSame('JD202610040001', $sheet->getCell('F2')->getValue());
unlink($temp);
}
@ -237,6 +247,43 @@ class StoreViolationAlertPageTest extends FeatureTestCase
$response->assertSessionHasErrors('import');
}
public function test_import_maps_reason_and_disposition_columns(): void
{
$this->allowPage();
$file = $this->makeWorkbook([
['店铺名', '违规单号', '违规原因', '处置方式'],
['京东-纪辰家具旗舰店', 'JD202610040088', '订单存在揽收超时行为', '警告;整改'],
]);
$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->assertIsArray($payload);
$this->assertSame(1, $payload['summary']['new']);
$commit = $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [
'preview_token' => $payload['token'],
]);
$commit->assertRedirect();
$record = DB::connection('business_mirror')
->table('store_violation_records')
->where('platform', 'jd')
->where('violation_no', 'JD202610040088')
->first();
$this->assertNotNull($record);
$this->assertSame('订单存在揽收超时行为', $record->violation_reason);
$this->assertSame('警告;整改', $record->disposition);
// 未提供违规类型时,违规分类按违规原因关键词自动归类。
$this->assertSame('发货履约', $record->violation_category);
}
/** @param list<list<mixed>> $rows */
private function makeWorkbook(array $rows): UploadedFile
{

View File

@ -71,6 +71,33 @@ class StoreViolationMapperTest extends TestCase
$this->assertNull($this->mapper->normalizePoints('不扣分'));
}
public function test_map_crawl_row_includes_reason_and_disposition_when_upstream_provides_them(): void
{
$base = [
'shop_name' => '京喜-久伴吾家具京喜自营工厂店',
'violation_no' => '86522327',
'violation_date' => '2026-09-30',
'deadline' => '2026-10-07',
'violation_content' => '订单存在揽收超时行为',
'status' => '待处理',
'window_name' => '京喜-久伴吾家具京喜自营工厂店_商家后台',
'platform' => '京喜',
'crawl_date' => '2026-10-04',
];
$mapped = $this->mapper->mapCrawlRow($base + [
'violation_reason' => '订单存在揽收超时行为',
'disposition' => '警告;扣2分',
]);
$this->assertSame('订单存在揽收超时行为', $mapped['violation_reason']);
$this->assertSame('警告;扣2分', $mapped['disposition']);
// 上游尚未提供两列时保持 null,避免同步误覆盖导入补充的数据。
$legacy = $this->mapper->mapCrawlRow($base);
$this->assertNull($legacy['violation_reason']);
$this->assertNull($legacy['disposition']);
}
public function test_unrecognized_points_throw(): void
{
$this->expectException(InvalidArgumentException::class);

View File

@ -0,0 +1,33 @@
<?php
namespace Tests\Unit\Console;
use App\Console\Commands\SyncStoreViolations;
use PHPUnit\Framework\TestCase;
/**
* 店铺违规同步:上游视图扩展列(违规原因/处置方式)的自动探测。
*/
class StoreViolationsSyncExtraColumnsTest extends TestCase
{
public function test_picks_no_columns_when_upstream_only_has_base_fields(): void
{
$this->assertSame([], SyncStoreViolations::pickExtraColumns([
'id', 'shop_name', 'violation_no', 'violation_date', 'deadline',
'violation_content', 'status', 'window_name', 'platform', 'crawl_date',
]));
}
public function test_picks_exact_and_alternative_column_names_case_insensitively(): void
{
$this->assertSame(
['violation_reason' => 'violation_reason', 'disposition' => 'disposition'],
SyncStoreViolations::pickExtraColumns(['id', 'violation_reason', 'disposition']),
);
$this->assertSame(
['violation_reason' => 'reason', 'disposition' => 'handling_method'],
SyncStoreViolations::pickExtraColumns(['Reason', 'HANDLING_METHOD']),
);
}
}