CaiWuXiTong/modules/EcommerceFinance/Services/CompanyAlipayBillImporter.php
92451 040d0d08ae feat: 接入公司支付宝账单与台账手动编辑 (#797)
接入公司非店铺支付宝账单、受限批处理导入,并为账户总表及每日资金流水增加管理员手动编辑与审计。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-30 20:03:04 +08:00

430 lines
16 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;
class CompanyAlipayBillImporter
{
public const SOURCE = 'alipay-company';
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,balance_date:string,account_short:string,rows:int,
* income:float,expense:float,replaced:int,recalculated:?array
* }
*/
public function import(
string $path,
string $accountNo,
string $balanceDate,
?string $accountShort = null,
bool $dryRun = false,
): array {
$date = $this->date($balanceDate);
$accountNo = $this->text($accountNo);
if ($accountNo === '') {
throw new InvalidArgumentException('支付宝账号不能为空。');
}
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('支付宝账单文件不存在或不可读取。');
}
$account = $this->account($accountNo, $accountShort);
$rows = $this->rows($path, $accountNo, $account->account_short, $date);
if ($rows === []) {
throw new InvalidArgumentException("支付宝账单中没有 {$date} 的有效交易,已保留原有动账明细。");
}
$income = round(array_sum(array_column($rows, 'income')), 2);
$expense = round(array_sum(array_column($rows, 'expense')), 2);
$replaced = $this->connection->table('fin_transactions')
->where('source', self::SOURCE)
->where('source_value', $accountNo)
->whereDate('txn_date', $date)
->count();
$result = [
'dry_run' => $dryRun,
'balance_date' => $date,
'account_short' => (string) $account->account_short,
'rows' => count($rows),
'income' => $income,
'expense' => $expense,
'replaced' => $replaced,
'recalculated' => null,
];
if ($dryRun) {
return $result;
}
$recalculated = $this->connection->transaction(function () use (
$rows,
$date,
$accountNo,
$account,
$income,
$expense,
): array {
$this->connection->table('fin_transactions')
->where('source', self::SOURCE)
->where('source_value', $accountNo)
->whereDate('txn_date', $date)
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table('fin_transactions')->insert($chunk);
}
$now = now();
$this->connection->table('fin_account_map')->updateOrInsert(
['source' => self::SOURCE, 'source_value' => $accountNo],
[
'account_short' => (string) $account->account_short,
'txn_count' => count($rows),
'income_sum' => $income,
'expense_sum' => $expense,
'updated_by' => '系统自动同步',
'updated_at' => $now,
'created_at' => $now,
],
);
return app(FinanceLedgerCalculator::class)->recalculate();
});
$result['recalculated'] = $recalculated;
return $result;
}
private function account(string $accountNo, ?string $accountShort): object
{
$query = $this->connection->table('fin_accounts')->where('account_no', $accountNo);
$accountShort = $this->text($accountShort);
if ($accountShort !== '') {
$query->where('account_short', $accountShort);
}
$accounts = $query->get(['id', 'account_short', 'account_no', 'account_type']);
if ($accounts->isEmpty()) {
throw new InvalidArgumentException('账户总表中没有账号为 ' . $accountNo . ' 的支付宝账户。');
}
if ($accounts->count() > 1) {
throw new InvalidArgumentException('支付宝账号对应多个账户,请同时指定账户简称。');
}
return $accounts->first();
}
/** @return list<array<string,mixed>> */
private function rows(string $path, string $accountNo, string $accountShort, 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;
}
$rawAmount = $this->amountText($value('amount'));
$direction = $value('direction');
[$income, $expense] = $this->amounts($rawAmount, $direction);
if ($income === 0.0 && $expense === 0.0) {
continue;
}
$recordCode = $value('record_code');
if ($recordCode === '') {
$recordCode = hash('sha256', implode("\0", [
$time, $direction, $rawAmount, $value('counterparty'), $value('description'), $value('merchant_order_no'),
]));
}
$uniqueKey = $recordCode . "\0" . $time . "\0" . number_format($income, 2, '.', '') . "\0" . number_format($expense, 2, '.', '');
if (isset($seen[$uniqueKey])) {
continue;
}
$seen[$uniqueKey] = true;
$counterparty = $value('counterparty');
$description = $value('description');
$paymentMethod = $value('payment_method');
$merchantOrderNo = $value('merchant_order_no');
$remark = $value('remark');
$detail = implode(' · ', array_values(array_filter([
$counterparty,
$description,
$remark,
], static fn (string $text): bool => $text !== '')));
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, $merchantOrderNo,
])),
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'created_by' => '系统自动同步',
'updated_by' => '系统自动同步',
];
}
return $rows;
}
/**
* @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' => ['交易号', '交易订单号', '支付宝交易号', '流水号'],
'direction' => ['收/支', '收支', '收支方向', '收入支出', '收入支出类型'],
'amount' => ['金额', '金额元', '金额(元)', '金额(元)', '交易金额'],
'counterparty' => ['交易对方', '对方账号', '交易对象'],
'description' => ['商品说明', '商品名称', '商品'],
'status' => ['交易状态', '状态'],
'payment_method' => ['收付款方式', '付款方式'],
'merchant_order_no' => ['商家订单号', '商户订单号'],
'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'], $fields['record_code'], $fields['direction'], $fields['amount'], $fields['status'])) {
throw new InvalidArgumentException('支付宝账单缺少交易时间、交易号、收支方向、金额或交易状态列。');
}
return $fields;
}
/** @return array{0:float,1:float} */
private function amounts(string $amount, string $direction): array
{
$negative = str_starts_with($amount, '-');
$value = round(abs((float) str_replace([',', ',', '¥', '¥'], '', $amount)), 2);
$direction = mb_strtolower($this->text($direction));
if (str_contains($direction, '不计') || str_contains($direction, '中性')) {
return [0.0, 0.0];
}
if (str_contains($direction, '支') || str_contains($direction, '出') || $negative) {
return [0.0, $value];
}
return [$value, 0.0];
}
private function amountText(string $value): string
{
return trim(str_replace([' ', ','], '', $value));
}
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 text(mixed $value): string
{
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
}
}