CaiWuXiTong/app/Business/StoreViolationWorkbookImporter.php

520 lines
18 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 店铺违规信息预警:导入文件按统一表头映射到 store_violation_records。
*
* preview() 只解析和校验,不写库;commit() 按 (platform, violation_no) 幂等写入。
*/
class StoreViolationWorkbookImporter
{
public function __construct(
private readonly StoreViolationMapper $mapper,
private readonly StoreViolationRecordRepository $repository,
private readonly StoreViolationAlertCalculator $alerts,
) {
}
/**
* @return array{platform:string,sheet:string,columns:list<array{key:string,label:string}>,unknown_headers:list<string>,rows:list<array>,summary:array,existing_checked:bool}
*/
public function preview(UploadedFile $file, string $platform): array
{
$platforms = (array) config('store_violations.platforms', []);
if (! array_key_exists($platform, $platforms)) {
throw ValidationException::withMessages(['import' => '未知平台,请刷新页面后重试。']);
}
if (! $this->repository->available()) {
throw ValidationException::withMessages(['import' => '导入功能尚未就绪:统一表尚未建立,请联系管理员完成迁移。']);
}
try {
$spreadsheet = IOFactory::load($file->getRealPath());
} catch (Throwable $exception) {
report($exception);
throw ValidationException::withMessages(['import' => '无法读取该文件,请上传 xlsx / xls / csv 格式的表格。']);
}
$worksheet = $spreadsheet->getActiveSheet();
$matrix = $worksheet->toArray(null, true, true, false);
[$headerIndex, $columnMap, $unknownHeaders] = $this->detectHeaders($matrix);
if ($headerIndex === null) {
throw ValidationException::withMessages([
'import' => '未找到可识别的表头行,请使用统一表头或下载导入模板。',
]);
}
$missing = [];
foreach (['shop_name', 'violation_no'] as $required) {
if (! in_array($required, $columnMap, true)) {
$missing[] = $this->labelFor($required);
}
}
if ($missing !== []) {
throw ValidationException::withMessages([
'import' => '缺少必填列:' . implode('、', $missing) . '。请使用统一表头或下载导入模板。',
]);
}
$maxRows = (int) config('store_violations.import.max_rows', 20000);
$dataRows = array_slice($matrix, $headerIndex + 1);
if (count($dataRows) > $maxRows) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . number_format($maxRows) . ' 行,请拆分文件后重试。',
]);
}
$rows = [];
$seen = [];
$invalid = 0;
foreach ($dataRows as $offset => $row) {
$rowNumber = $headerIndex + 2 + $offset;
$raw = $this->rowToFields($row, $columnMap);
if ($this->isEmptyRow($raw)) {
continue;
}
$item = $this->normalizeRow($raw, $platform, $rowNumber, $columnMap);
if ($item['status'] === 'valid') {
$key = $platform . '|' . $item['data']['violation_no'];
if (isset($seen[$key])) {
$item = $seen[$key]['data'] === $item['data']
? $this->markRow($item, 'same', '与文件内前一行重复,将跳过')
: $this->markRow($item, 'invalid', '与文件内前一行的数据冲突,请先合并');
} else {
$seen[$key] = $item;
}
}
if ($item['status'] === 'invalid') {
$invalid++;
}
$rows[] = $item;
}
$summary = ['new' => 0, 'update' => 0, 'same' => 0, 'invalid' => $invalid];
$this->compareWithExisting($rows, $platform, $summary);
return [
'platform' => $platform,
'sheet' => $worksheet->getTitle(),
'columns' => collect((array) config('store_violations.columns', []))
->map(fn (array $column): array => ['key' => $column['key'], 'label' => $column['label']])
->all(),
'unknown_headers' => $unknownHeaders,
'provided_columns' => array_values($columnMap),
'rows' => $rows,
'summary' => $summary,
];
}
/** @param array{rows:list<array>} $preview */
public function commit(array $preview): array
{
$platform = (string) ($preview['platform'] ?? '');
$rows = $preview['rows'] ?? [];
$provided = array_values(array_unique((array) ($preview['provided_columns'] ?? [])));
$now = now();
$unifiedColumns = array_map(
static fn (array $column): string => (string) $column['key'],
(array) config('store_violations.columns', []),
);
$updateColumns = array_values(array_intersect($unifiedColumns, $provided));
if (! in_array('violation_category', $updateColumns, true) && in_array('violation_type', $provided, true)) {
$updateColumns[] = 'violation_category';
}
$updateColumns[] = 'source';
$updateColumns[] = 'updated_at';
// 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。
$updateColumns = array_values(array_unique(array_merge(
$updateColumns,
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
)));
$payload = [];
$created = 0;
$updated = 0;
foreach ($rows as $row) {
$status = $row['status'] ?? '';
if (! in_array($status, ['new', 'update'], true)) {
continue;
}
$data = $row['data'] ?? [];
if (($data['platform'] ?? null) !== $platform || ($data['violation_no'] ?? '') === '') {
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;
}
}
$payload[] = $data + [
'source' => 'import',
'created_at' => $now,
'updated_at' => $now,
];
$status === 'new' ? $created++ : $updated++;
}
$affected = $this->repository->upsertRows($payload, $updateColumns);
return ['created' => $created, 'updated' => $updated, 'affected' => $affected];
}
/**
* @param list<list<mixed>> $matrix
* @return array{0:?int,1:list<string>,2:list<string>}
*/
private function detectHeaders(array $matrix): array
{
$bestIndex = null;
$bestMap = [];
$bestUnknown = [];
$bestScore = 0;
foreach ($matrix as $index => $row) {
$map = [];
$unknown = [];
foreach ($row as $cell) {
$label = trim((string) $cell);
if ($label === '') {
continue;
}
$key = $this->matchHeader($label);
if ($key === null) {
$unknown[] = $label;
continue;
}
if (! in_array($key, $map, true)) {
$map[] = $key;
}
}
if (count($map) > $bestScore) {
$bestIndex = $index;
$bestMap = $map;
$bestUnknown = $unknown;
$bestScore = count($map);
}
// 命中 6 列以上视为表头,避免把数据行误判为表头
if ($bestScore >= 6) {
break;
}
}
if ($bestIndex === null || $bestScore < 2) {
return [null, [], []];
}
return [$bestIndex, $bestMap, array_values(array_unique($bestUnknown))];
}
private function matchHeader(string $label): ?string
{
$normalized = mb_strtolower(preg_replace('/[\s"* ]/u', '', $label) ?? $label);
foreach ((array) config('store_violations.header_aliases', []) as $key => $aliases) {
foreach ((array) $aliases as $alias) {
$alias = mb_strtolower(preg_replace('/[\s"* ]/u', '', (string) $alias) ?? (string) $alias);
if ($alias !== '' && $normalized === $alias) {
return (string) $key;
}
}
}
return null;
}
/**
* @param list<mixed> $row
* @param list<string> $columnMap
* @return array<string,mixed>
*/
private function rowToFields(array $row, array $columnMap): array
{
$fields = [];
foreach ($row as $index => $cell) {
$key = $columnMap[$index] ?? null;
if ($key !== null) {
$fields[$key] = $cell;
}
}
return $fields;
}
/** @param array<string,mixed> $fields */
private function isEmptyRow(array $fields): bool
{
foreach ($fields as $value) {
if (trim((string) $value) !== '') {
return false;
}
}
return true;
}
/**
* @param array<string,mixed> $raw
* @return array{row_number:int,status:string,message:string,data:array<string,mixed>}
*/
private function normalizeRow(array $raw, string $platform, int $rowNumber, array $provided): array
{
$errors = [];
$data = ['platform' => $platform];
$has = static fn (string $key): bool => in_array($key, $provided, true);
if ($has('shop_name')) {
$shopName = trim((string) ($raw['shop_name'] ?? ''));
if ($shopName === '') {
$errors[] = '店铺名不能为空';
} elseif (mb_strlen($shopName) > 191) {
$errors[] = '店铺名超过 191 字';
}
$data['shop_name'] = mb_substr($shopName, 0, 191);
}
if ($has('violation_no')) {
$violationNo = trim((string) ($raw['violation_no'] ?? ''));
if ($violationNo === '') {
$errors[] = '违规单号不能为空';
} elseif (mb_strlen($violationNo) > 100) {
$errors[] = '违规单号超过 100 字';
}
$data['violation_no'] = mb_substr($violationNo, 0, 100);
}
$type = $has('violation_type') ? trim((string) ($raw['violation_type'] ?? '')) : null;
if ($has('violation_type')) {
$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($categorySource)
: mb_substr($category, 0, 64);
} 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')) {
try {
$data['violation_date'] = $this->mapper->normalizeDate($raw['violation_date'] ?? null);
} catch (Throwable $exception) {
$errors[] = '违规日期:' . $exception->getMessage();
$data['violation_date'] = null;
}
}
if ($has('points_deducted')) {
try {
$data['points_deducted'] = $this->mapper->normalizePoints($raw['points_deducted'] ?? null);
} catch (Throwable $exception) {
$errors[] = '扣分:' . $exception->getMessage();
$data['points_deducted'] = null;
}
}
if ($has('status')) {
$status = trim((string) ($raw['status'] ?? ''));
$data['status'] = $status === '' ? null : mb_substr($status, 0, 64);
}
if ($has('penalty_date')) {
try {
$data['penalty_date'] = $this->mapper->normalizeDate($raw['penalty_date'] ?? null);
} catch (Throwable $exception) {
$errors[] = '处罚日期:' . $exception->getMessage();
$data['penalty_date'] = null;
}
}
if ($has('appeal_deadline')) {
try {
$data['appeal_deadline'] = $this->mapper->normalizeDate($raw['appeal_deadline'] ?? null);
} catch (Throwable $exception) {
$errors[] = '申诉截止日期:' . $exception->getMessage();
$data['appeal_deadline'] = null;
}
}
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', '未处理');
} elseif (! in_array($handling, $options, true)) {
$errors[] = '处理状态必须是:' . implode('、', $options);
$data['handling_status'] = $handling;
} else {
$data['handling_status'] = $handling;
}
}
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',
'message' => $errors === [] ? '待导入' : implode(';', $errors),
'data' => $data,
];
}
private function markRow(array $row, string $status, string $message): array
{
$row['status'] = $status;
$row['message'] = $message;
return $row;
}
/**
* 与库内已有记录比对:无 → new,字段完全一致 → same,否则 → update。
*
* @param list<array> $rows
*/
private function compareWithExisting(array &$rows, string $platform, array &$summary): void
{
$keys = collect($rows)
->filter(fn (array $row): bool => $row['status'] === 'valid' && ($row['data']['violation_no'] ?? '') !== '')
->pluck('data.violation_no')
->unique()
->values();
if ($keys->isEmpty()) {
return;
}
$existing = collect();
foreach ($keys->chunk(500) as $chunk) {
$existing = $existing->merge(
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table(StoreViolationRecordRepository::TABLE)
->where('platform', $platform)
->whereIn('violation_no', $chunk->all())
->get()
->keyBy('violation_no'),
);
}
foreach ($rows as $index => $row) {
if ($row['status'] !== 'valid') {
continue;
}
$record = $existing->get($row['data']['violation_no']);
if ($record === null) {
$summary['new']++;
$rows[$index] = $this->markRow($row, 'new', '新增记录');
continue;
}
$rows[$index] = $this->sameAsExisting($row['data'], $record)
? $this->markRow($row, 'same', '与库内记录一致,将跳过')
: $this->markRow($row, 'update', '库内已有该单号,将按文件内容更新');
$rows[$index]['status'] === 'same' ? $summary['same']++ : $summary['update']++;
}
}
private function sameAsExisting(array $data, object $record): bool
{
$normalizePoints = static function (mixed $value): string {
if ($value === null || $value === '') {
return '';
}
return number_format((float) $value, 2, '.', '');
};
foreach ($data as $key => $value) {
if ($key === 'platform') {
continue;
}
$current = $record->{$key} ?? null;
if ($key === 'points_deducted') {
if ($normalizePoints($value) !== $normalizePoints($current)) {
return false;
}
continue;
}
if (trim((string) $value) !== trim((string) $current)) {
return false;
}
}
return true;
}
private function labelFor(string $key): string
{
foreach ((array) config('store_violations.columns', []) as $column) {
if (($column['key'] ?? null) === $key) {
return (string) $column['label'];
}
}
return $key;
}
}