CaiWuXiTong/modules/EcommerceFinance/Services/WangshangBankBillImporter.php
92451 67c85678e6 feat: 接入网商银行每日动账明细 (#831)
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-11-02 20:58:42 +08:00

684 lines
27 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}