CaiWuXiTong/modules/EcommerceFinance/Services/StoreReportDataImporter.php

345 lines
15 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use DateTimeInterface;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
class StoreReportDataImporter
{
private const PLATFORMS = [
'dewu' => ['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<int,string> */
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<array{0:array<string,int>,1:array<int,mixed>}> */
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(),
]);
}
}