345 lines
15 KiB
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(),
|
|
]);
|
|
}
|
|
}
|