,unknown_headers:list,rows:list,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} $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> $matrix * @return array{0:?int,1:list,2:list} */ 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 $row * @param list $columnMap * @return array */ 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 $fields */ private function isEmptyRow(array $fields): bool { foreach ($fields as $value) { if (trim((string) $value) !== '') { return false; } } return true; } /** * @param array $raw * @return array{row_number:int,status:string,message:string,data:array} */ 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 $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; } }