520 lines
18 KiB
PHP
520 lines
18 KiB
PHP
<?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;
|
||
}
|
||
}
|