684 lines
27 KiB
PHP
684 lines
27 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\DatabaseManager;
|
||
use InvalidArgumentException;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use ZipArchive;
|
||
|
||
class WangshangBankBillImporter
|
||
{
|
||
public const SOURCE = 'wangshang-bank';
|
||
|
||
public const PROCESS_TYPE = '网商银行';
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(DatabaseManager $databases)
|
||
{
|
||
$this->connection = $databases->connection(
|
||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @return array{
|
||
* dry_run:bool,date:string,rows:int,income:float,expense:float,
|
||
* accounts:list<array<string,mixed>>,recalculated:?array
|
||
* }
|
||
*/
|
||
public function import(string $path, string $balanceDate, bool $dryRun = false): array
|
||
{
|
||
$date = $this->date($balanceDate);
|
||
if (! is_file($path) || ! is_readable($path)) {
|
||
throw new InvalidArgumentException('网商银行批次文件不存在或不可读取。');
|
||
}
|
||
|
||
$temporaryDirectory = null;
|
||
try {
|
||
[$manifestPath, $temporaryDirectory] = $this->bundle($path);
|
||
$manifest = $this->manifest($manifestPath, $date);
|
||
$entries = $this->entries($manifest, dirname($manifestPath), $date);
|
||
$result = [
|
||
'dry_run' => $dryRun,
|
||
'date' => $date,
|
||
'rows' => array_sum(array_column($entries, 'rows')),
|
||
'income' => round(array_sum(array_column($entries, 'income')), 2),
|
||
'expense' => round(array_sum(array_column($entries, 'expense')), 2),
|
||
'accounts' => array_map(static fn (array $entry): array => [
|
||
'company' => $entry['company'],
|
||
'account_last4' => substr($entry['account'], -4),
|
||
'expected_rows' => $entry['expected_rows'],
|
||
'rows' => $entry['rows'],
|
||
'income' => $entry['income'],
|
||
'expense' => $entry['expense'],
|
||
], $entries),
|
||
'recalculated' => null,
|
||
];
|
||
if ($dryRun) {
|
||
return $result;
|
||
}
|
||
|
||
$result['recalculated'] = $this->persist($entries, $date);
|
||
|
||
return $result;
|
||
} finally {
|
||
if ($temporaryDirectory !== null) {
|
||
$this->removeDirectory($temporaryDirectory);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array{0:string,1:?string}
|
||
*/
|
||
private function bundle(string $path): array
|
||
{
|
||
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||
if ($extension === 'json') {
|
||
return [$path, null];
|
||
}
|
||
if ($extension !== 'zip' || ! class_exists(ZipArchive::class)) {
|
||
throw new InvalidArgumentException('网商银行批次必须上传 ZIP 或 manifest.json。');
|
||
}
|
||
|
||
$directory = sys_get_temp_dir() . '/caiwu-wangshang-' . bin2hex(random_bytes(8));
|
||
if (! mkdir($directory, 0700, true) && ! is_dir($directory)) {
|
||
throw new RuntimeException('无法创建网商银行批次临时目录。');
|
||
}
|
||
|
||
$archive = new ZipArchive();
|
||
$opened = $archive->open($path);
|
||
if ($opened !== true) {
|
||
$this->removeDirectory($directory);
|
||
throw new InvalidArgumentException('网商银行批次 ZIP 无法打开。');
|
||
}
|
||
|
||
try {
|
||
for ($index = 0; $index < $archive->numFiles; $index++) {
|
||
$name = str_replace('\\', '/', (string) $archive->getNameIndex($index));
|
||
if ($name === '' || str_starts_with($name, '/') || preg_match('/(^|\/)\.\.(\/|$)/', $name) === 1) {
|
||
throw new InvalidArgumentException('网商银行批次 ZIP 包含不安全路径。');
|
||
}
|
||
if (str_ends_with($name, '/')) {
|
||
$target = $directory . '/' . $name;
|
||
if (! is_dir($target) && ! mkdir($target, 0700, true) && ! is_dir($target)) {
|
||
throw new RuntimeException('无法创建网商银行批次临时目录。');
|
||
}
|
||
continue;
|
||
}
|
||
$source = $archive->getStream($name);
|
||
if ($source === false) {
|
||
throw new InvalidArgumentException('网商银行批次 ZIP 内容无法读取。');
|
||
}
|
||
$target = $directory . '/' . $name;
|
||
$parent = dirname($target);
|
||
if (! is_dir($parent) && ! mkdir($parent, 0700, true) && ! is_dir($parent)) {
|
||
fclose($source);
|
||
throw new RuntimeException('无法创建网商银行批次临时目录。');
|
||
}
|
||
$destination = fopen($target, 'wb');
|
||
if ($destination === false) {
|
||
fclose($source);
|
||
throw new RuntimeException('无法写入网商银行批次临时文件。');
|
||
}
|
||
stream_copy_to_stream($source, $destination);
|
||
fclose($source);
|
||
fclose($destination);
|
||
}
|
||
} finally {
|
||
$archive->close();
|
||
}
|
||
|
||
$manifestPath = $directory . '/manifest.json';
|
||
if (! is_file($manifestPath)) {
|
||
$this->removeDirectory($directory);
|
||
throw new InvalidArgumentException('网商银行批次缺少 manifest.json。');
|
||
}
|
||
|
||
return [$manifestPath, $directory];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function manifest(string $path, string $date): array
|
||
{
|
||
try {
|
||
$manifest = json_decode(
|
||
(string) file_get_contents($path),
|
||
true,
|
||
512,
|
||
JSON_THROW_ON_ERROR,
|
||
);
|
||
} catch (Throwable $exception) {
|
||
throw new InvalidArgumentException('网商银行 manifest.json 无法解析:' . $exception->getMessage());
|
||
}
|
||
if (! is_array($manifest) || ! is_array($manifest['accounts'] ?? null) || $manifest['accounts'] === []) {
|
||
throw new InvalidArgumentException('网商银行 manifest 中没有账户明细。');
|
||
}
|
||
if (trim((string) ($manifest['date'] ?? '')) !== $date) {
|
||
throw new InvalidArgumentException('网商银行 manifest 日期与导入日期不一致。');
|
||
}
|
||
|
||
return $manifest;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $manifest
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function entries(array $manifest, string $directory, string $date): array
|
||
{
|
||
$entries = [];
|
||
$seenAccounts = [];
|
||
|
||
foreach ($manifest['accounts'] as $item) {
|
||
if (! is_array($item)) {
|
||
throw new InvalidArgumentException('网商银行 manifest 账户格式无效。');
|
||
}
|
||
$account = $this->accountNo((string) ($item['account'] ?? ''));
|
||
if (isset($seenAccounts[$account])) {
|
||
throw new InvalidArgumentException('网商银行 manifest 包含重复账号。');
|
||
}
|
||
$seenAccounts[$account] = true;
|
||
|
||
$company = $this->text($item['company'] ?? '');
|
||
$accountShort = $this->text($item['account_short'] ?? '');
|
||
if ($company === '' || $accountShort === '') {
|
||
throw new InvalidArgumentException('网商银行 manifest 缺少公司或账户简称。');
|
||
}
|
||
$expectedRows = array_key_exists('expected_rows', $item)
|
||
? filter_var($item['expected_rows'], FILTER_VALIDATE_INT)
|
||
: null;
|
||
if ($expectedRows === false || ($expectedRows !== null && $expectedRows < 0)) {
|
||
throw new InvalidArgumentException('网商银行 manifest 页面笔数无效。');
|
||
}
|
||
$expectedIncome = $this->expectedMoney($item['expected_income'] ?? null);
|
||
$expectedExpense = $this->expectedMoney($item['expected_expense'] ?? null);
|
||
|
||
$relative = trim((string) ($item['file'] ?? ''));
|
||
$rows = [];
|
||
if ($relative !== '') {
|
||
if (str_starts_with($relative, '/') || preg_match('/(^|\/)\.\.(\/|$)/', $relative) === 1) {
|
||
throw new InvalidArgumentException('网商银行 manifest 文件路径无效。');
|
||
}
|
||
$file = $directory . '/' . str_replace('\\', '/', $relative);
|
||
if (! is_file($file) || ! is_readable($file)) {
|
||
throw new InvalidArgumentException('网商银行 manifest 引用的明细文件不存在。');
|
||
}
|
||
$rows = $this->rows($file, $account, $accountShort, $company, $date);
|
||
}
|
||
|
||
if ($expectedRows !== null && count($rows) !== $expectedRows) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'%s网商账户页面显示 %d 笔,但下载文件有效行为 %d 笔。',
|
||
mb_substr($company, 0, 30),
|
||
$expectedRows,
|
||
count($rows),
|
||
));
|
||
}
|
||
if ($rows === [] && ($expectedRows === null || $expectedRows > 0)) {
|
||
throw new InvalidArgumentException(mb_substr($company, 0, 30) . '网商账户没有目标日期的有效交易。');
|
||
}
|
||
$income = round(array_sum(array_column($rows, 'income')), 2);
|
||
$expense = round(array_sum(array_column($rows, 'expense')), 2);
|
||
if (($expectedIncome !== null && abs($income - $expectedIncome) > 0.01)
|
||
|| ($expectedExpense !== null && abs($expense - $expectedExpense) > 0.01)) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'%s网商账户页面金额与下载文件不一致:页面收入 %.2f、支出 %.2f,文件收入 %.2f、支出 %.2f。',
|
||
mb_substr($company, 0, 30),
|
||
$expectedIncome ?? $income,
|
||
$expectedExpense ?? $expense,
|
||
$income,
|
||
$expense,
|
||
));
|
||
}
|
||
|
||
$entries[] = [
|
||
'company' => $company,
|
||
'account' => $account,
|
||
'account_short' => $this->resolvedAccountShort($account, $accountShort),
|
||
'expected_rows' => $expectedRows,
|
||
'rows' => count($rows),
|
||
'income' => $income,
|
||
'expense' => $expense,
|
||
'transactions' => $rows,
|
||
];
|
||
}
|
||
|
||
return $entries;
|
||
}
|
||
|
||
private function resolvedAccountShort(string $accountNo, string $fallback): string
|
||
{
|
||
$matches = $this->connection->table('fin_accounts')
|
||
->get(['account_short', 'account_no'])
|
||
->filter(static fn (object $account): bool =>
|
||
preg_replace('/\D+/', '', (string) $account->account_no) === $accountNo
|
||
)
|
||
->values();
|
||
if ($matches->count() > 1) {
|
||
throw new InvalidArgumentException('网商账号在账户总表中对应多个账户,请先在账户总表处理重复项。');
|
||
}
|
||
if ($matches->count() === 1) {
|
||
$matched = $this->text($matches->first()->account_short);
|
||
if ($matched !== '') {
|
||
return $matched;
|
||
}
|
||
}
|
||
|
||
// 账户总表尚未补齐时仍可先展示在“动账明细”;余额计算会等账户建好后再纳入。
|
||
return $fallback;
|
||
}
|
||
|
||
/**
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function rows(
|
||
string $path,
|
||
string $accountNo,
|
||
string $accountShort,
|
||
string $company,
|
||
string $date,
|
||
): array {
|
||
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||
[$headers, $dataRows] = match ($extension) {
|
||
'csv' => $this->csvRows($path),
|
||
'xlsx', 'xls' => $this->workbookRows($path),
|
||
default => throw new InvalidArgumentException('网商银行明细仅支持 CSV、XLSX 或 XLS 文件。'),
|
||
};
|
||
$fields = $this->fields($headers);
|
||
$rows = [];
|
||
$seen = [];
|
||
|
||
foreach ($dataRows as $values) {
|
||
$value = static function (string $field) use ($values, $fields): string {
|
||
$index = $fields[$field] ?? null;
|
||
|
||
return $index === null ? '' : trim((string) ($values[$index] ?? ''));
|
||
};
|
||
$time = $this->dateTime($value('time'));
|
||
if ($time === null || substr($time, 0, 10) !== $date) {
|
||
continue;
|
||
}
|
||
$status = $value('status');
|
||
if ($status !== '' && preg_match('/关闭|失败|取消|退票/u', $status) === 1) {
|
||
continue;
|
||
}
|
||
|
||
$income = abs($this->money($value('income')));
|
||
$expense = abs($this->money($value('expense')));
|
||
if ($income === 0.0 && $expense === 0.0 && isset($fields['amount'])) {
|
||
$rawAmount = $value('amount');
|
||
$amount = abs($this->money($rawAmount));
|
||
$direction = mb_strtolower($this->text($value('direction')));
|
||
if (str_starts_with(trim($rawAmount), '-')
|
||
|| str_contains($direction, '支')
|
||
|| str_contains($direction, '出')
|
||
|| str_contains($direction, '贷')) {
|
||
$expense = $amount;
|
||
} else {
|
||
$income = $amount;
|
||
}
|
||
}
|
||
if ($income === 0.0 && $expense === 0.0) {
|
||
continue;
|
||
}
|
||
|
||
$recordCode = $this->text($value('record_code'));
|
||
if ($recordCode === '') {
|
||
$recordCode = hash('sha256', implode("\0", [
|
||
$time,
|
||
$value('description'),
|
||
$value('counterparty'),
|
||
number_format($income, 2, '.', ''),
|
||
number_format($expense, 2, '.', ''),
|
||
]));
|
||
}
|
||
$uniqueKey = $recordCode . "\0" . $time . "\0"
|
||
. number_format($income, 2, '.', '') . "\0"
|
||
. number_format($expense, 2, '.', '');
|
||
if (isset($seen[$uniqueKey])) {
|
||
continue;
|
||
}
|
||
$seen[$uniqueKey] = true;
|
||
|
||
$detailParts = array_values(array_filter([
|
||
$this->text($value('description')),
|
||
$this->text($value('counterparty')),
|
||
$this->text($value('remark')),
|
||
], static fn (string $text): bool => $text !== ''));
|
||
$detail = implode(' · ', $detailParts);
|
||
if ($detail === '') {
|
||
$detail = '网商银行交易 ' . $recordCode;
|
||
}
|
||
|
||
$now = now();
|
||
$rows[] = [
|
||
'txn_date' => $date,
|
||
'account_short' => $accountShort,
|
||
'detail' => mb_substr($detail, 0, 4000),
|
||
'income' => $income,
|
||
'expense' => $expense,
|
||
'approval_no' => mb_substr($recordCode, 0, 100),
|
||
'source' => self::SOURCE,
|
||
'source_value' => $accountNo,
|
||
'process_type' => self::PROCESS_TYPE,
|
||
'row_key' => hash('sha256', implode("\0", [
|
||
self::SOURCE,
|
||
$accountNo,
|
||
$recordCode,
|
||
$time,
|
||
number_format($income, 2, '.', ''),
|
||
number_format($expense, 2, '.', ''),
|
||
$company,
|
||
])),
|
||
'synced_at' => $now,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
'created_by' => '系统自动同步',
|
||
'updated_by' => '系统自动同步',
|
||
];
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string,mixed>> $entries
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function persist(array $entries, string $date): array
|
||
{
|
||
return $this->connection->transaction(function () use ($entries, $date): array {
|
||
foreach ($entries as $entry) {
|
||
$this->connection->table('fin_transactions')
|
||
->where('source', self::SOURCE)
|
||
->where('source_value', $entry['account'])
|
||
->whereDate('txn_date', $date)
|
||
->delete();
|
||
foreach (array_chunk($entry['transactions'], 500) as $chunk) {
|
||
$this->connection->table('fin_transactions')->insert($chunk);
|
||
}
|
||
|
||
$stats = $this->connection->table('fin_transactions')
|
||
->where('source', self::SOURCE)
|
||
->where('source_value', $entry['account'])
|
||
->selectRaw('COUNT(*) AS txn_count, COALESCE(SUM(income), 0) AS income_sum, COALESCE(SUM(expense), 0) AS expense_sum')
|
||
->first();
|
||
$this->connection->table('fin_account_map')->updateOrInsert(
|
||
['source' => self::SOURCE, 'source_value' => $entry['account']],
|
||
[
|
||
'account_short' => $entry['account_short'],
|
||
'txn_count' => (int) ($stats->txn_count ?? 0),
|
||
'income_sum' => round((float) ($stats->income_sum ?? 0), 2),
|
||
'expense_sum' => round((float) ($stats->expense_sum ?? 0), 2),
|
||
'updated_by' => '系统自动同步',
|
||
'updated_at' => now(),
|
||
'created_at' => now(),
|
||
],
|
||
);
|
||
}
|
||
|
||
return app(FinanceLedgerCalculator::class)->recalculate();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @return array{0:list<string>,1:list<list<string>>}
|
||
*/
|
||
private function csvRows(string $path): array
|
||
{
|
||
[$headers, $headerRow] = $this->csvHeader($path);
|
||
|
||
return [$headers, (function () use ($path, $headerRow): \Generator {
|
||
$stream = $this->openCsvStream($path);
|
||
$foundHeader = false;
|
||
try {
|
||
while (($row = fgetcsv($stream)) !== false) {
|
||
$values = array_map(static fn (mixed $value): string => trim((string) $value), $row);
|
||
if (! $foundHeader) {
|
||
$foundHeader = $values === $headerRow;
|
||
continue;
|
||
}
|
||
|
||
yield $values;
|
||
}
|
||
} finally {
|
||
fclose($stream);
|
||
}
|
||
})()];
|
||
}
|
||
|
||
/**
|
||
* @return array{0:list<string>,1:list<string>}
|
||
*/
|
||
private function csvHeader(string $path): array
|
||
{
|
||
$stream = $this->openCsvStream($path);
|
||
try {
|
||
while (($row = fgetcsv($stream)) !== false) {
|
||
$values = array_map(static fn (mixed $value): string => trim((string) $value), $row);
|
||
$headers = array_map(fn (string $value): string => $this->header($value), $values);
|
||
if ($this->hasAny($headers, ['交易时间', '记账时间', '发生时间', '入账时间'])
|
||
&& $this->hasAny($headers, [
|
||
'借方金额(收)', '借方金额(收)', '借方金额', '收入', '收入金额',
|
||
'贷方金额(支)', '贷方金额(支)', '贷方金额', '支出', '支出金额',
|
||
'金额', '交易金额',
|
||
])) {
|
||
return [$headers, $values];
|
||
}
|
||
}
|
||
} finally {
|
||
fclose($stream);
|
||
}
|
||
|
||
throw new InvalidArgumentException('未找到网商银行交易时间与金额表头。');
|
||
}
|
||
|
||
/** @return resource */
|
||
private function openCsvStream(string $path)
|
||
{
|
||
$stream = fopen($path, 'rb');
|
||
if ($stream === false) {
|
||
throw new RuntimeException('无法读取网商银行 CSV 文件。');
|
||
}
|
||
$sample = fread($stream, 8192);
|
||
if ($sample === false || trim($sample) === '') {
|
||
fclose($stream);
|
||
throw new InvalidArgumentException('网商银行 CSV 文件为空。');
|
||
}
|
||
$hasBom = str_starts_with($sample, "\xEF\xBB\xBF");
|
||
if (! mb_check_encoding($hasBom ? substr($sample, 3) : $sample, 'UTF-8')) {
|
||
stream_filter_append($stream, 'convert.iconv.GB18030/UTF-8', STREAM_FILTER_READ);
|
||
}
|
||
rewind($stream);
|
||
if ($hasBom) {
|
||
fseek($stream, 3);
|
||
}
|
||
|
||
return $stream;
|
||
}
|
||
|
||
/**
|
||
* @return array{0:list<string>,1:list<list<string>>}
|
||
*/
|
||
private function workbookRows(string $path): array
|
||
{
|
||
$spreadsheet = IOFactory::load($path);
|
||
$matrix = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
|
||
$spreadsheet->disconnectWorksheets();
|
||
|
||
return $this->headerAndRows(array_map(
|
||
static fn (array $row): array => array_map(static fn (mixed $value): string => trim((string) $value), $row),
|
||
$matrix,
|
||
));
|
||
}
|
||
|
||
/**
|
||
* @param list<list<string>> $matrix
|
||
* @return array{0:list<string>,1:list<list<string>>}
|
||
*/
|
||
private function headerAndRows(array $matrix): array
|
||
{
|
||
foreach ($matrix as $index => $row) {
|
||
$headers = array_map(fn (string $value): string => $this->header($value), $row);
|
||
$hasTime = $this->hasAny($headers, ['交易时间', '记账时间', '发生时间', '入账时间']);
|
||
$hasAmount = $this->hasAny($headers, [
|
||
'借方金额(收)', '借方金额(收)', '借方金额', '收入', '收入金额',
|
||
'贷方金额(支)', '贷方金额(支)', '贷方金额', '支出', '支出金额',
|
||
'金额', '交易金额',
|
||
]);
|
||
if ($hasTime && $hasAmount) {
|
||
return [$headers, array_values(array_slice($matrix, $index + 1))];
|
||
}
|
||
}
|
||
|
||
throw new InvalidArgumentException('未找到网商银行交易时间与金额表头。');
|
||
}
|
||
|
||
/** @param list<string> $headers @return array<string,int> */
|
||
private function fields(array $headers): array
|
||
{
|
||
$aliases = [
|
||
'time' => ['交易时间', '记账时间', '发生时间', '入账时间'],
|
||
'record_code' => ['账务流水号', '流水号', '交易流水号', '交易单号'],
|
||
'income' => ['借方金额(收)', '借方金额(收)', '借方金额', '收入', '收入金额', '收款金额'],
|
||
'expense' => ['贷方金额(支)', '贷方金额(支)', '贷方金额', '支出', '支出金额', '付款金额'],
|
||
'amount' => ['交易金额', '金额(元)', '金额(元)', '金额'],
|
||
'direction' => ['收/支', '收支', '借贷标志', '收支方向', '交易方向'],
|
||
'description' => ['交易名称', '摘要', '用途', '商品说明'],
|
||
'counterparty' => ['对方户名', '交易对手', '对方名称', '交易对方'],
|
||
'status' => ['交易状态', '状态'],
|
||
'remark' => ['备注', '附言'],
|
||
];
|
||
$fields = [];
|
||
foreach ($aliases as $field => $names) {
|
||
foreach ($names as $name) {
|
||
$index = array_search($name, $headers, true);
|
||
if ($index !== false) {
|
||
$fields[$field] = $index;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (! isset($fields['time']) || ! $this->hasAny($headers, $aliases['income'])
|
||
&& ! $this->hasAny($headers, $aliases['expense']) && ! isset($fields['amount'])) {
|
||
throw new InvalidArgumentException('网商银行账单缺少交易时间或金额列。');
|
||
}
|
||
|
||
return $fields;
|
||
}
|
||
|
||
private function money(string $value): float
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '' || $value === '-' || $value === '--') {
|
||
return 0.0;
|
||
}
|
||
$negative = str_starts_with($value, '-');
|
||
$number = preg_replace('/[^\d.]/', '', $value);
|
||
if ($number === '' || $number === null) {
|
||
return 0.0;
|
||
}
|
||
|
||
return round(abs((float) $number), 2) * ($negative ? -1 : 1);
|
||
}
|
||
|
||
private function expectedMoney(mixed $value): ?float
|
||
{
|
||
if ($value === null || $value === '') {
|
||
return null;
|
||
}
|
||
if (! is_numeric($value)) {
|
||
throw new InvalidArgumentException('网商银行 manifest 页面金额无效。');
|
||
}
|
||
|
||
return round(abs((float) $value), 2);
|
||
}
|
||
|
||
private function dateTime(string $value): ?string
|
||
{
|
||
$value = trim($value);
|
||
if (is_numeric($value)) {
|
||
try {
|
||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
if (preg_match('/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $matches) !== 1) {
|
||
return null;
|
||
}
|
||
[$year, $month, $day, $hour, $minute] = array_map('intval', array_slice($matches, 1, 5));
|
||
$second = isset($matches[6]) && $matches[6] !== '' ? (int) $matches[6] : 0;
|
||
if (! checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) {
|
||
return null;
|
||
}
|
||
|
||
return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $year, $month, $day, $hour, $minute, $second);
|
||
}
|
||
|
||
/** @param list<string> $headers @param list<string> $names */
|
||
private function hasAny(array $headers, array $names): bool
|
||
{
|
||
return array_intersect($headers, $names) !== [];
|
||
}
|
||
|
||
private function header(string $value): string
|
||
{
|
||
return preg_replace('/[\s ]+/u', '', trim($value)) ?? trim($value);
|
||
}
|
||
|
||
private function date(string $value): string
|
||
{
|
||
$date = CarbonImmutable::createFromFormat('!Y-m-d', trim($value));
|
||
if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== trim($value)) {
|
||
throw new InvalidArgumentException('账单日期必须使用 YYYY-MM-DD 格式。');
|
||
}
|
||
|
||
return $date->format('Y-m-d');
|
||
}
|
||
|
||
private function accountNo(string $value): string
|
||
{
|
||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||
if (strlen($digits) < 6 || strlen($digits) > 30) {
|
||
throw new InvalidArgumentException('网商银行账号格式无效。');
|
||
}
|
||
|
||
return $digits;
|
||
}
|
||
|
||
private function text(mixed $value): string
|
||
{
|
||
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
|
||
}
|
||
|
||
private function removeDirectory(string $directory): void
|
||
{
|
||
$normalized = rtrim(str_replace('\\', '/', $directory), '/');
|
||
$temporary = rtrim(str_replace('\\', '/', sys_get_temp_dir()), '/');
|
||
if (! str_starts_with($normalized . '/', $temporary . '/caiwu-wangshang-')) {
|
||
return;
|
||
}
|
||
if (! is_dir($normalized)) {
|
||
return;
|
||
}
|
||
|
||
$items = new \RecursiveIteratorIterator(
|
||
new \RecursiveDirectoryIterator($normalized, \FilesystemIterator::SKIP_DOTS),
|
||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||
);
|
||
foreach ($items as $item) {
|
||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||
}
|
||
rmdir($normalized);
|
||
}
|
||
}
|