208 lines
8.0 KiB
PHP
208 lines
8.0 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\DatabaseManager;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use InvalidArgumentException;
|
||
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||
use RuntimeException;
|
||
|
||
class AlibabaAccountBalanceWorkbookImporter
|
||
{
|
||
private const HEADERS = [
|
||
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
|
||
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
|
||
];
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(DatabaseManager $database)
|
||
{
|
||
$this->connection = $database->connection(
|
||
(string) config('upstream_mirrors.connection', 'business_mirror')
|
||
);
|
||
}
|
||
|
||
/** @return array{created:int,updated:int,skipped:int,total:int} */
|
||
public function import(string $path, ?string $sourceFileName = null, bool $dryRun = false): array
|
||
{
|
||
if (! is_file($path) || ! is_readable($path)) {
|
||
throw new InvalidArgumentException('余额文件不存在或不可读取。');
|
||
}
|
||
|
||
$rows = $this->readRows($path);
|
||
if ($rows === []) {
|
||
throw new InvalidArgumentException('余额文件中没有可导入的数据行。');
|
||
}
|
||
|
||
if (! Schema::connection($this->connection->getName())->hasTable(AlibabaAccountBalanceRepository::TABLE)) {
|
||
throw new RuntimeException('阿里巴巴余额快照表尚未创建,请先执行数据库迁移。');
|
||
}
|
||
|
||
$created = 0;
|
||
$updated = 0;
|
||
$sourceFileName = mb_substr(basename($sourceFileName ?: $path), 0, 255);
|
||
$sourceSha256 = hash_file('sha256', $path);
|
||
$now = now();
|
||
|
||
$this->connection->transaction(function () use (
|
||
$rows, $sourceFileName, $sourceSha256, $now, $dryRun, &$created, &$updated
|
||
): void {
|
||
foreach ($rows as $row) {
|
||
$identity = [
|
||
'balance_date' => $row['balance_date'],
|
||
'store_name' => $row['store_name'],
|
||
];
|
||
$exists = $this->connection->table(AlibabaAccountBalanceRepository::TABLE)
|
||
->where($identity)
|
||
->exists();
|
||
$exists ? $updated++ : $created++;
|
||
|
||
if ($dryRun) {
|
||
continue;
|
||
}
|
||
|
||
$values = $row + [
|
||
'source_file_name' => $sourceFileName,
|
||
'source_sha256' => $sourceSha256,
|
||
'imported_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
|
||
if ($exists) {
|
||
$this->connection->table(AlibabaAccountBalanceRepository::TABLE)
|
||
->where($identity)
|
||
->update($values);
|
||
} else {
|
||
$this->connection->table(AlibabaAccountBalanceRepository::TABLE)
|
||
->insert($values + ['created_at' => $now]);
|
||
}
|
||
}
|
||
});
|
||
|
||
return ['created' => $created, 'updated' => $updated, 'skipped' => 0, 'total' => count($rows)];
|
||
}
|
||
|
||
/** @return array<int,array<string,mixed>> */
|
||
private function readRows(string $path): array
|
||
{
|
||
$spreadsheet = IOFactory::load($path);
|
||
$worksheet = $spreadsheet->getSheetByName('阿里巴巴账户') ?: $spreadsheet->getActiveSheet();
|
||
$headerRow = $this->findHeaderRow($worksheet);
|
||
$rows = [];
|
||
$seen = [];
|
||
|
||
for ($rowNumber = $headerRow + 1; $rowNumber <= $worksheet->getHighestDataRow(); $rowNumber++) {
|
||
$values = [];
|
||
foreach (range(1, count(self::HEADERS)) as $column) {
|
||
$values[] = $worksheet->getCell([$column, $rowNumber])->getCalculatedValue();
|
||
}
|
||
|
||
if (collect($values)->every(static fn (mixed $value): bool => $value === null || trim((string) $value) === '')) {
|
||
continue;
|
||
}
|
||
|
||
$date = $this->date($values[0] ?? null, $rowNumber);
|
||
$storeName = trim((string) ($values[1] ?? ''));
|
||
if ($storeName === '') {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”不能为空。");
|
||
}
|
||
if (mb_strlen($storeName) > 255) {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“店铺名称”超过 255 个字符。");
|
||
}
|
||
|
||
$key = $date . "\0" . $storeName;
|
||
if (isset($seen[$key])) {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和店铺名称重复。");
|
||
}
|
||
$seen[$key] = $rowNumber;
|
||
|
||
$rows[] = [
|
||
'balance_date' => $date,
|
||
'store_name' => $storeName,
|
||
'opening_balance' => $this->number($values[2] ?? null, $rowNumber, '期初余额'),
|
||
'income_total' => $this->number($values[3] ?? null, $rowNumber, '本期收入'),
|
||
'expense_total' => $this->number($values[4] ?? null, $rowNumber, '本期支出'),
|
||
'closing_balance' => $this->number($values[5] ?? null, $rowNumber, '期末余额'),
|
||
'system_closing_balance' => $this->number($values[6] ?? null, $rowNumber, '系统期末余额'),
|
||
'balance_difference' => $this->number($values[7] ?? null, $rowNumber, '余额差异'),
|
||
'difference_reason' => $this->text($values[8] ?? null),
|
||
'verification_status' => $this->text($values[9] ?? null, 64, $rowNumber, '核对状态'),
|
||
'notes' => $this->text($values[10] ?? null),
|
||
];
|
||
}
|
||
|
||
$spreadsheet->disconnectWorksheets();
|
||
|
||
return $rows;
|
||
}
|
||
|
||
private function findHeaderRow(Worksheet $worksheet): int
|
||
{
|
||
$maximum = min($worksheet->getHighestDataRow(), 20);
|
||
for ($row = 1; $row <= $maximum; $row++) {
|
||
$headers = [];
|
||
foreach (range(1, count(self::HEADERS)) as $column) {
|
||
$headers[] = trim((string) $worksheet->getCell([$column, $row])->getValue());
|
||
}
|
||
|
||
if ($headers === self::HEADERS) {
|
||
return $row;
|
||
}
|
||
}
|
||
|
||
throw new InvalidArgumentException('未找到阿里巴巴账户余额的 11 列标准表头。');
|
||
}
|
||
|
||
private function date(mixed $value, int $rowNumber): string
|
||
{
|
||
try {
|
||
$date = is_numeric($value)
|
||
? CarbonImmutable::instance(ExcelDate::excelToDateTimeObject((float) $value))
|
||
: CarbonImmutable::parse((string) $value);
|
||
} catch (\Throwable) {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”不是有效日期。");
|
||
}
|
||
|
||
if (! $date->isLastOfMonth()) {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“日期”必须是月末日期。");
|
||
}
|
||
|
||
return $date->format('Y-m-d');
|
||
}
|
||
|
||
private function number(mixed $value, int $rowNumber, string $label): ?float
|
||
{
|
||
if ($value === null || trim((string) $value) === '') {
|
||
return null;
|
||
}
|
||
|
||
$normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
|
||
if (! is_numeric($normalized)) {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效金额。");
|
||
}
|
||
|
||
return round((float) $normalized, 6);
|
||
}
|
||
|
||
private function text(mixed $value, ?int $limit = null, ?int $rowNumber = null, ?string $label = null): ?string
|
||
{
|
||
if ($value === null || trim((string) $value) === '') {
|
||
return null;
|
||
}
|
||
|
||
$text = trim((string) $value);
|
||
if ($limit !== null && mb_strlen($text) > $limit) {
|
||
throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”超过 {$limit} 个字符。");
|
||
}
|
||
|
||
return $text;
|
||
}
|
||
}
|