['label' => '得物', 'markers' => ['得物']], 'douyin' => ['label' => '抖音', 'markers' => ['头条放心购', '抖音']], 'taobao' => ['label' => '淘系', 'markers' => ['淘系']], 'jd' => ['label' => '京东', 'markers' => ['京东商城', '京东']], 'pinduoduo' => ['label' => '拼多多', 'markers' => ['拼多多']], ]; private const TIME_HEADERS = [ 'dewu' => ['结算时间', '支付/退款时间', '发生时间'], 'douyin' => ['结算时间', '发生时间'], 'taobao' => ['结算时间', '入账时间', '发生时间'], 'jd' => ['结算时间', '费用结算时间', '发生时间'], 'pinduoduo' => ['结算时间', '发生时间'], ]; private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $databases) { $this->connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** @return array{dataset_key:string,row_count:int,generation:string} */ public function importBills(string $path, int $batchSize = 500): array { $files = $this->xlsxFiles($path); $generation = (string) Str::uuid(); $dataset = StoreReportRepository::BILLS_DATASET; $this->begin($dataset, $generation); $batchSize = min(max($batchSize, 100), 2000); $batch = []; $count = 0; try { foreach ($files as $file) { [$platform, $label] = $this->platform($file); foreach ($this->spreadsheetRows($file) as [$headers, $values]) { $storeName = $this->text($this->value($headers, $values, ['店铺', '店铺名称'])); $recordCode = $this->text($this->value($headers, $values, ['记录编码', '账单流水号', '业务单号'])); if ($storeName === null || $recordCode === null) { continue; } $occurredAt = $this->dateTime($this->value($headers, $values, self::TIME_HEADERS[$platform])); $income = $this->money($this->value($headers, $values, ['收入金额', '收入'])); $expense = $this->money($this->value($headers, $values, ['支出金额', '支出'])); $billItem = $this->text($this->value($headers, $values, ['账单项目', '摘要'])); $business = [$platform, $recordCode, $storeName, $occurredAt, $income, $expense, $billItem]; $batch[] = [ 'generation' => $generation, 'platform' => $platform, 'platform_label' => $label, 'store_name' => $storeName, 'occurred_at' => $occurredAt, 'income' => $income, 'expense' => $expense, 'bill_item' => $billItem, 'record_code' => $recordCode, 'source_file' => basename($file), 'row_hash' => hash('sha256', json_encode($business, JSON_UNESCAPED_UNICODE)), 'imported_at' => now(), ]; if (count($batch) >= $batchSize) { $count += $this->storeBills($batch); $batch = []; } } } if ($batch !== []) { $count += $this->storeBills($batch); } $count = (int) $this->connection->table('store_report_bill_rows') ->where('generation', $generation)->count(); if ($count === 0) { throw new RuntimeException('平台账单文件中没有可导入的数据。'); } $this->activate($dataset, $generation, $count, $files); } catch (Throwable $exception) { $this->fail($dataset, $generation, 'store_report_bill_rows', $exception); throw $exception; } return ['dataset_key' => $dataset, 'row_count' => $count, 'generation' => $generation]; } /** @return array{dataset_key:string,row_count:int,generation:string} */ public function importLifecycles(string $path, int $batchSize = 500): array { $files = $this->xlsxFiles($path); if (count($files) !== 1) { throw new RuntimeException('店铺生命周期导入只接受一个 XLSX 文件。'); } $file = $files[0]; $generation = (string) Str::uuid(); $dataset = StoreReportRepository::LIFECYCLES_DATASET; $this->begin($dataset, $generation); $batchSize = min(max($batchSize, 100), 2000); $batch = []; $count = 0; try { foreach ($this->spreadsheetRows($file) as [$headers, $values]) { $storeName = $this->text($this->value($headers, $values, ['ERP系统店铺名称'])); if ($storeName === null) { continue; } $companyName = $this->text($this->value($headers, $values, ['公司名称'])); $effectiveAt = $this->dateTime($this->value($headers, $values, ['生效时间'])); $expiresAt = $this->dateTime($this->value($headers, $values, ['失效时间']), true); $business = [$storeName, $companyName, $effectiveAt, $expiresAt]; $batch[] = [ 'generation' => $generation, 'business_key' => hash('sha256', implode('|', [$storeName, $effectiveAt ?? '', $expiresAt ?? ''])), 'erp_store_name' => $storeName, 'company_name' => $companyName, 'effective_at' => $effectiveAt, 'expires_at' => $expiresAt, 'source_file' => basename($file), 'row_hash' => hash('sha256', json_encode($business, JSON_UNESCAPED_UNICODE)), 'imported_at' => now(), ]; if (count($batch) >= $batchSize) { $count += $this->storeLifecycles($batch); $batch = []; } } if ($batch !== []) { $count += $this->storeLifecycles($batch); } $count = (int) $this->connection->table('store_report_lifecycle_rows') ->where('generation', $generation)->count(); if ($count === 0) { throw new RuntimeException('店铺生命周期文件中没有可导入的数据。'); } $this->activate($dataset, $generation, $count, [$file]); } catch (Throwable $exception) { $this->fail($dataset, $generation, 'store_report_lifecycle_rows', $exception); throw $exception; } return ['dataset_key' => $dataset, 'row_count' => $count, 'generation' => $generation]; } /** @return array */ private function xlsxFiles(string $path): array { $real = realpath($path); if ($real === false) { throw new RuntimeException('导入路径不存在。'); } $files = is_dir($real) ? glob($real . DIRECTORY_SEPARATOR . '*.xlsx') : [$real]; $files = array_values(array_filter($files ?: [], static fn (string $file): bool => is_file($file) && is_readable($file) && ! str_starts_with(basename($file), '~$') )); sort($files); if ($files === []) { throw new RuntimeException('导入路径中没有可读的 XLSX 文件。'); } return $files; } /** @return array{0:string,1:string} */ private function platform(string $file): array { foreach (self::PLATFORMS as $key => $definition) { foreach ($definition['markers'] as $marker) { if (str_contains(basename($file), $marker)) { return [$key, $definition['label']]; } } } throw new RuntimeException('无法从文件名识别账单平台:' . basename($file)); } /** @return iterable,1:array}> */ private function spreadsheetRows(string $file): iterable { $reader = IOFactory::createReaderForFile($file); $reader->setReadDataOnly(true); $spreadsheet = $reader->load($file); try { foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { $rows = $worksheet->toArray(null, true, true, false); $headerRow = array_shift($rows); if (! is_array($headerRow)) { continue; } $headers = []; foreach ($headerRow as $index => $header) { $name = $this->text($header); if ($name !== null) { $headers[$name] = $index; } } foreach ($rows as $values) { if (array_filter($values, static fn (mixed $value): bool => $value !== null && $value !== '') === []) { continue; } yield [$headers, $values]; } } } finally { $spreadsheet->disconnectWorksheets(); unset($spreadsheet); } } private function value(array $headers, array $values, array $candidates): mixed { foreach ($candidates as $candidate) { if (array_key_exists($candidate, $headers)) { $value = $values[$headers[$candidate]] ?? null; if ($value !== null && $value !== '') { return $value; } } } return null; } private function text(mixed $value): ?string { $value = trim((string) ($value ?? '')); return $value === '' ? null : mb_substr($value, 0, 255); } private function money(mixed $value): float { $normalized = str_replace([',', '¥', '¥', ' '], '', (string) ($value ?? '0')); if (! is_numeric($normalized)) { throw new RuntimeException('账单金额无法解析。'); } return round((float) $normalized, 4); } private function dateTime(mixed $value, bool $allowOpenEnded = false): ?string { if ($value === null || $value === '') { return null; } if ($allowOpenEnded && in_array(trim((string) $value), ['存续中', '至今', '长期', '无'], true)) { return null; } if ($value instanceof DateTimeInterface) { return $value->format('Y-m-d H:i:s'); } if (is_numeric($value)) { return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s'); } try { return Carbon::parse(str_replace('/', '-', trim((string) $value)))->format('Y-m-d H:i:s'); } catch (Throwable) { throw new RuntimeException('账单或生命周期时间无法解析。'); } } private function begin(string $dataset, string $generation): void { $this->assertSchemaReady(); $now = now(); $this->connection->table('store_report_import_states')->insertOrIgnore([ 'dataset_key' => $dataset, 'status' => 'pending', 'row_count' => 0, 'created_at' => $now, 'updated_at' => $now, ]); $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ 'status' => 'running', 'pending_generation' => $generation, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); } private function assertSchemaReady(): void { $schema = Schema::connection($this->connection->getName()); foreach (['store_report_import_states', 'store_report_bill_rows', 'store_report_lifecycle_rows'] as $table) { if (! $schema->hasTable($table)) { throw new RuntimeException('店铺维度报表共享镜像尚未完成迁移:' . $table); } } } private function storeBills(array $rows): int { $this->connection->table('store_report_bill_rows')->upsert( $rows, ['generation', 'platform', 'record_code'], ['platform_label', 'store_name', 'occurred_at', 'income', 'expense', 'bill_item', 'source_file', 'row_hash', 'imported_at'], ); return count($rows); } private function storeLifecycles(array $rows): int { $this->connection->table('store_report_lifecycle_rows')->upsert( $rows, ['generation', 'business_key'], ['erp_store_name', 'company_name', 'effective_at', 'expires_at', 'source_file', 'row_hash', 'imported_at'], ); return count($rows); } private function activate(string $dataset, string $generation, int $count, array $files): void { $hashes = array_map(static fn (string $file): string => hash_file('sha256', $file) ?: '', $files); $now = now(); $this->connection->transaction(function () use ($dataset, $generation, $count, $files, $hashes, $now): void { $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ 'status' => 'succeeded', 'active_generation' => $generation, 'pending_generation' => null, 'row_count' => $count, 'source_sha256' => hash('sha256', implode('|', $hashes)), 'source_files_json' => json_encode(array_map('basename', $files), JSON_UNESCAPED_UNICODE), 'last_succeeded_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); }); } private function fail(string $dataset, string $generation, string $table, Throwable $exception): void { $this->connection->table($table)->where('generation', $generation)->delete(); $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ 'status' => 'failed', 'pending_generation' => null, 'last_error_code' => class_basename($exception), 'updated_at' => now(), ]); } }