CaiWuXiTong/modules/EcommerceFinance/Services/PayablesFinalImportRunner.php
苏木 88d24d61bb fix: 终版导入不再阻断重复内容行 (#877)
移除终版普通导入和大文件分片导入的重复内容异常判断。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-05 09:20:27 +08:00

375 lines
13 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 Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Validation\ValidationException;
use Throwable;
/**
* 大文件导入(20 万行级):上传时把工作簿转成紧凑的行缓存(jsonl.gz),
* 之后由前端按片调用 process(),每片控制行数与耗时,避免超时与内存爆掉。
*/
class PayablesFinalImportRunner
{
/** 单次导入的行数上限。 */
public const MAX_ROWS = 200000;
/** 每片默认处理的行数。 */
private const SLICE_ROWS = 20000;
/** 每累计多少行落一次库。 */
private const FLUSH_ROWS = 2000;
/** 单片的时间预算(秒),到点就返回让前端继续下一片。 */
private const SLICE_SECONDS = 25.0;
/** 未完成任务的保留时间(小时),过期自动清理。 */
private const STALE_HOURS = 24;
public function __construct(
private readonly PayablesFieldNormalizer $normalizer,
private readonly SupplierPayableOverrideStore $store,
) {
}
public function storagePath(): string
{
return storage_path('app/payables-final-imports');
}
/**
* 上传文件 → 建立行缓存。
*
* @param array<string,array{label:string,type:string}> $fields
* @return array{token:string,total:int}
*/
public function prepare(UploadedFile $file, array $fields, string $storeDataset, int $companyId, ?int $userId): array
{
$this->cleanupStale();
$token = bin2hex(random_bytes(16));
$directory = $this->directory($token);
if (! is_dir($directory) && ! mkdir($directory, 0770, true)) {
throw ValidationException::withMessages(['import' => '无法创建导入任务目录。']);
}
$extension = strtolower((string) $file->getClientOriginalExtension());
$source = (string) $file->getRealPath();
$headerRow = 0;
try {
$reader = PayablesWorkbookStreamReader::forFile($source, $extension);
$header = $this->locateHeader($reader, $fields);
if ($header === null) {
throw ValidationException::withMessages([
'import' => '没有识别到表头,请先下载本页面的导入模板,按模板列填写后重试。',
]);
}
[$sheetIndex, $headerRow, $columns] = $header;
$rows = 0;
$handle = gzopen($directory . '/rows.jsonl.gz', 'wb1');
if ($handle === false) {
throw ValidationException::withMessages(['import' => '无法写入导入缓存。']);
}
try {
foreach ($reader->rows($sheetIndex, $headerRow) as $row) {
$values = [];
foreach ($columns as $columnIndex => $field) {
$values[$field] = $row[$columnIndex] ?? null;
}
$rows++;
if ($rows > self::MAX_ROWS) {
throw ValidationException::withMessages([
'import' => '单次最多导入 200,000 条记录,请拆分文件后重试。',
]);
}
gzwrite($handle, (string) json_encode($values, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . "\n");
}
} finally {
gzclose($handle);
}
if ($rows === 0) {
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
}
} catch (Throwable $exception) {
$this->delete($token);
if ($exception instanceof ValidationException) {
throw $exception;
}
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
}
$this->writeMeta($token, [
'dataset' => $storeDataset,
'company_id' => $companyId,
'user_id' => $userId,
'fields' => $fields,
'first_row' => $headerRow + 1,
'rows' => $rows,
'offset' => 0,
'created' => 0,
'updated' => 0,
'status' => 'ready',
'error' => null,
'started_at' => now()->toIso8601String(),
]);
return ['token' => $token, 'total' => $rows];
}
/**
* 处理一片;返回最新进度。
*
* @return array{offset:int,total:int,created:int,updated:int,done:bool}
*/
public function process(string $token, int $offset, ?int $maxRows = null, ?int $companyId = null): array
{
$meta = $this->readMeta($token);
if ($meta === null) {
throw ValidationException::withMessages(['import' => '导入任务不存在或已过期,请重新上传文件。']);
}
if ($companyId !== null && (int) $meta['company_id'] !== $companyId) {
throw ValidationException::withMessages(['import' => '导入任务不存在或已过期,请重新上传文件。']);
}
if (($meta['status'] ?? '') === 'done') {
return $this->progress($meta);
}
$total = (int) $meta['rows'];
$firstRow = (int) ($meta['first_row'] ?? 2);
$dataset = (string) $meta['dataset'];
$companyId = (int) $meta['company_id'];
$userId = isset($meta['user_id']) ? (int) $meta['user_id'] : null;
$fields = is_array($meta['fields'] ?? null) ? $meta['fields'] : [];
$sliceRows = max(1, $maxRows ?? self::SLICE_ROWS);
$deadline = microtime(true) + self::SLICE_SECONDS;
$created = (int) $meta['created'];
$updated = (int) $meta['updated'];
$line = 0;
$processed = 0;
$records = [];
$offset = min(max($offset, 0), $total);
$handle = gzopen($this->directory($token) . '/rows.jsonl.gz', 'rb');
if ($handle === false) {
throw ValidationException::withMessages(['import' => '导入任务数据已损坏,请重新上传文件。']);
}
try {
while ($line < $total && $processed < $sliceRows) {
$raw = gzgets($handle);
if ($raw === false) {
break;
}
$line++;
if ($line <= $offset) {
continue;
}
$values = json_decode((string) rtrim($raw, "\r\n"), true);
if (! is_array($values)) {
$values = [];
}
if (collect($values)->every(static fn (mixed $value): bool => trim((string) $value) === '')) {
continue;
}
try {
$normalized = $this->normalizer->normalize($fields, $values);
} catch (ValidationException $exception) {
$this->fail($meta, $line - 1, $created, $updated, $this->rowMessage($firstRow, $line, $exception));
throw ValidationException::withMessages([
'import' => $this->rowMessage($firstRow, $line, $exception),
]);
}
$key = $this->store->key($dataset, $normalized);
$records[$key] = $normalized;
$processed++;
if (count($records) >= self::FLUSH_ROWS) {
$counts = $this->store->saveBatch($dataset, $records, $companyId, $userId, 'import');
$created += $counts['created'];
$updated += $counts['updated'];
$records = [];
if (microtime(true) >= $deadline || $processed >= $sliceRows) {
break;
}
}
}
if ($records !== []) {
$counts = $this->store->saveBatch($dataset, $records, $companyId, $userId, 'import');
$created += $counts['created'];
$updated += $counts['updated'];
}
} finally {
gzclose($handle);
}
$done = $line >= $total;
$meta['offset'] = $line;
$meta['created'] = $created;
$meta['updated'] = $updated;
$meta['status'] = $done ? 'done' : 'running';
$meta['error'] = null;
$this->writeMeta($token, $meta);
if ($done) {
$this->delete($token);
}
return $this->progress($meta);
}
/** @return array{offset:int,total:int,created:int,updated:int,done:bool} */
private function progress(array $meta): array
{
return [
'offset' => (int) ($meta['offset'] ?? 0),
'total' => (int) ($meta['rows'] ?? 0),
'created' => (int) ($meta['created'] ?? 0),
'updated' => (int) ($meta['updated'] ?? 0),
'done' => ($meta['status'] ?? '') === 'done',
];
}
private function rowMessage(int $firstRow, int $line, ValidationException $exception): string
{
return '第 ' . ($firstRow + $line - 1) . ' 行:' . collect($exception->errors())->flatten()->first();
}
private function fail(array $meta, int $offset, int $created, int $updated, string $message): void
{
$meta['offset'] = $offset;
$meta['created'] = $created;
$meta['updated'] = $updated;
$meta['status'] = 'failed';
$meta['error'] = $message;
$this->writeMeta((string) $meta['token'], $meta);
}
/**
* 前若干行里找出表头(列序号 => 字段),与旧版打分规则一致。
*
* @param array<string,array{label:string,type:string}> $fields
* @return array{0:int,1:int,2:array<int,string>}|null
*/
private function locateHeader(PayablesWorkbookStreamReader $reader, array $fields): ?array
{
$lookup = ['记录标识' => '_action_key', 'recordkey' => '_action_key', '_action_key' => '_action_key'];
foreach ($fields as $field => $definition) {
$lookup[$this->header($field)] = $field;
$lookup[$this->header((string) $definition['label'])] = $field;
}
$best = null;
$score = 0;
foreach (array_keys($reader->sheetNames()) as $sheetIndex) {
foreach ($reader->rows($sheetIndex, null, 20) as $rowNumber => $row) {
$columns = [];
foreach ($row as $columnIndex => $value) {
$key = $this->header($value);
if ($key !== '' && isset($lookup[$key])) {
$columns[$columnIndex] = $lookup[$key];
}
}
if (count($columns) > $score) {
$score = count($columns);
$best = [$sheetIndex, $rowNumber, $columns];
}
}
}
return $best;
}
private function header(mixed $value): string
{
return mb_strtolower((string) preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
}
private function directory(string $token): string
{
return $this->storagePath() . '/' . $token;
}
private function metaPath(string $token): string
{
return $this->directory($token) . '/meta.json';
}
/** @return array<string,mixed>|null */
private function readMeta(string $token): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $token) !== 1) {
return null;
}
$path = $this->metaPath($token);
if (! is_file($path)) {
return null;
}
$meta = json_decode((string) file_get_contents($path), true);
if (! is_array($meta)) {
return null;
}
return $meta + ['token' => $token];
}
private function writeMeta(string $token, array $meta): void
{
$meta['token'] = $token;
$meta['updated_at'] = now()->toIso8601String();
file_put_contents($this->metaPath($token), (string) json_encode($meta, JSON_UNESCAPED_UNICODE));
}
public function delete(string $token): void
{
if (preg_match('/^[a-f0-9]{32}$/', $token) !== 1) {
return;
}
File::deleteDirectory($this->directory($token));
}
/** 清理过期任务(超过 24 小时未完成)。 */
private function cleanupStale(): void
{
$root = $this->storagePath();
if (! is_dir($root)) {
return;
}
$deadline = time() - self::STALE_HOURS * 3600;
foreach (File::directories($root) as $directory) {
$metaPath = $directory . '/meta.json';
$modified = is_file($metaPath) ? (int) filemtime($metaPath) : (int) filemtime($directory);
if ($modified < $deadline) {
$this->delete(basename($directory));
}
}
}
}