CaiWuXiTong/modules/EcommerceFinance/Services/CompanyAlipayStatementImporter.php

393 lines
15 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 App\Utilities\FinanceLedgerIndependentAccounts;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
/**
* 导入支付宝“账务组合查询”账户流水。
*
* 与单日支付宝交易导入不同,此导入以账户流水为权威来源,保留对方信息、
* 交易后余额和付款备注,并按日期范围替换已存在的同账户重复来源。
*/
class CompanyAlipayStatementImporter
{
public const SOURCE = CompanyAlipayBillImporter::SOURCE;
public const PROCESS_TYPE = '公司支付宝账务流水';
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly FinanceLedgerCalculator $calculator,
private readonly FinanceLedgerTransactionWriter $writer,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param list<string> $replaceSources
* @return array<string,mixed>
*/
public function import(
string $path,
string $accountNo,
string $from,
string $to,
?string $accountShort = null,
?float $openingBalance = null,
array $replaceSources = [],
bool $dryRun = false,
): array {
$from = $this->date($from);
$to = $this->date($to);
if ($from > $to) {
throw new InvalidArgumentException('支付宝流水起始日期不能晚于结束日期。');
}
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('支付宝流水文件不存在或不可读取。');
}
$accountNo = $this->text($accountNo);
$account = $this->account($accountNo, $accountShort);
$rows = $this->rows($path, $accountNo, (string) $account->account_short, $from, $to);
if ($rows === []) {
throw new InvalidArgumentException("支付宝流水中没有 {$from} 至 {$to} 的有效交易。");
}
$income = round(array_sum(array_column($rows, 'income')), 2);
$expense = round(array_sum(array_column($rows, 'expense')), 2);
$first = $rows[0];
$last = $rows[array_key_last($rows)];
$derivedOpening = round((float) $first['balance'] - (float) $first['income'] + (float) $first['expense'], 2);
if ($openingBalance !== null && abs(round($openingBalance, 2) - $derivedOpening) > 0.001) {
throw new InvalidArgumentException(sprintf(
'期初余额不一致:指定 %.2f,按首笔余额倒推为 %.2f。',
$openingBalance,
$derivedOpening,
));
}
$openingBalance = $openingBalance === null ? $derivedOpening : round($openingBalance, 2);
$expectedClosing = round($openingBalance + $income - $expense, 2);
if (abs($expectedClosing - (float) $last['balance']) > 0.001) {
throw new InvalidArgumentException(sprintf(
'账户余额未对平:期初加收支应为 %.2f,文件末笔余额为 %.2f。',
$expectedClosing,
(float) $last['balance'],
));
}
if (FinanceLedgerIndependentAccounts::contains($account->account_short) && $replaceSources === []) {
$replaceSources = [self::SOURCE];
}
$replaceIds = $this->replacementIds($account, $from, $to, $replaceSources);
$result = [
'dry_run' => $dryRun,
'from' => $from,
'to' => $to,
'account_short' => (string) $account->account_short,
'opening_balance' => $openingBalance,
'closing_balance' => $expectedClosing,
'rows' => count($rows),
'income' => $income,
'expense' => $expense,
'replaced' => count($replaceIds),
'recalculated' => null,
];
if ($dryRun) {
return $result;
}
$result['recalculated'] = $this->connection->transaction(function () use (
$rows,
$replaceIds,
$accountNo,
$account,
$from,
$to,
$openingBalance,
$income,
$expense,
): array {
foreach (array_chunk($replaceIds, 500) as $ids) {
$this->connection->table('fin_transactions')->whereIn('id', $ids)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table('fin_transactions')->upsert(
$chunk,
['row_key'],
[
'txn_date', 'account_short', 'counterparty_info', 'detail', 'income', 'expense',
'balance', 'approval_no', 'payment_remark', 'source', 'source_value',
'process_type', 'synced_at', 'updated_at', 'created_by', 'updated_by',
],
);
}
$now = now();
$this->connection->table('fin_account_opening')
->where('account_short', (string) $account->account_short)
->delete();
$this->connection->table('fin_account_opening')->insert([
'account_short' => (string) $account->account_short,
'opening_date' => $from,
'opening_balance' => $openingBalance,
'remark' => '支付宝账务组合查询期初',
'created_at' => $now,
'updated_at' => $now,
]);
$this->connection->table('fin_account_map')->updateOrInsert(
['source' => self::SOURCE, 'source_value' => $accountNo],
[
'account_short' => (string) $account->account_short,
'updated_by' => '系统自动同步',
'updated_at' => $now,
'created_at' => $now,
],
);
$this->writer->refreshMappingStats(false);
if (Schema::connection($this->connection->getName())->hasTable('fin_ledger_edit_events')) {
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => 'alipay_statement',
'entity_key' => self::SOURCE . '|' . $accountNo . '|' . $from . '|' . $to,
'changes' => json_encode([
'account_short' => (string) $account->account_short,
'from' => $from,
'to' => $to,
'rows' => count($rows),
'income' => $income,
'expense' => $expense,
'opening_balance' => $openingBalance,
'replaced_rows' => count($replaceIds),
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? '系统自动同步'),
'created_at' => $now,
]);
}
return $this->calculator->recalculate();
});
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']);
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 $from,
string $to,
): array {
$spreadsheet = IOFactory::load($path);
$matrix = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
$spreadsheet->disconnectWorksheets();
$headers = [];
$headerIndex = null;
foreach ($matrix as $index => $row) {
$candidate = array_map(fn (mixed $value): string => $this->header((string) $value), $row);
if (in_array('入账时间', $candidate, true)
&& in_array('收入(+元)', $candidate, true)
&& in_array('支出(-元)', $candidate, true)
&& in_array('账户余额(元)', $candidate, true)) {
$headers = $candidate;
$headerIndex = $index;
break;
}
}
if ($headerIndex === null) {
throw new InvalidArgumentException('未找到支付宝账务组合查询表头。');
}
$fields = [
'time' => '入账时间',
'record_code' => '支付宝交易号',
'process_type' => '账务类型',
'income' => '收入(+元)',
'expense' => '支出(-元)',
'balance' => '账户余额(元)',
'counterparty_account' => '对方账户',
'counterparty_name' => '对方名称',
'payment_remark' => '付款备注',
];
$indexes = [];
foreach ($fields as $key => $label) {
$index = array_search($label, $headers, true);
if ($index === false) {
throw new InvalidArgumentException('支付宝流水缺少字段:' . $label . '。');
}
$indexes[$key] = $index;
}
$rows = [];
$seen = [];
foreach (array_slice($matrix, $headerIndex + 1) as $values) {
$value = static function (string $key) use ($values, $indexes): string {
return trim((string) ($values[$indexes[$key]] ?? ''));
};
$time = $this->dateTime($value('time'));
if ($time === null) {
continue;
}
$date = substr($time, 0, 10);
if ($date < $from || $date > $to) {
continue;
}
$income = $this->money($value('income'));
$expense = $this->money($value('expense'));
if ($income === 0.0 && $expense === 0.0) {
continue;
}
$balance = $this->money($value('balance'));
$recordCode = $value('record_code');
if ($recordCode === '') {
throw new InvalidArgumentException('支付宝流水存在缺少交易号的记录。');
}
$uniqueKey = implode("\0", [$recordCode, $time, $income, $expense, $balance]);
if (isset($seen[$uniqueKey])) {
continue;
}
$seen[$uniqueKey] = true;
$counterparty = implode(' / ', array_values(array_filter([
$value('counterparty_account'),
$value('counterparty_name'),
])));
$paymentRemark = $value('payment_remark');
$processType = $value('process_type');
$detail = $counterparty !== ''
? $counterparty
: ($paymentRemark !== '' ? $paymentRemark : $processType);
$now = now();
$rows[] = [
'txn_date' => $date,
'account_short' => $accountShort,
'counterparty_info' => $counterparty !== '' ? mb_substr($counterparty, 0, 255) : null,
'detail' => mb_substr($detail !== '' ? $detail : '支付宝交易 ' . $recordCode, 0, 4000),
'income' => $income,
'expense' => $expense,
'balance' => $balance,
'approval_no' => mb_substr($recordCode, 0, 100),
'payment_remark' => $paymentRemark !== '' ? mb_substr($paymentRemark, 0, 500) : null,
'source' => self::SOURCE,
'source_value' => $accountNo,
'process_type' => self::PROCESS_TYPE,
'row_key' => hash('sha256', implode("\0", [
self::SOURCE, $accountNo, $recordCode, $time, $income, $expense, $balance,
])),
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'created_by' => '系统自动同步',
'updated_by' => '系统自动同步',
];
}
return $rows;
}
/**
* @param list<string> $sources
* @return list<int>
*/
private function replacementIds(object $account, string $from, string $to, array $sources): array
{
$sources = array_values(array_unique(array_filter(array_map(
fn (mixed $value): string => $this->text($value),
$sources,
))));
if ($sources === []) {
return [];
}
return $this->connection->table('fin_transactions as t')
->leftJoin('fin_account_map as m', function ($join): void {
$join->on('m.source', '=', 't.source')
->on('m.source_value', '=', 't.source_value');
})
->whereNull('t.deleted_at')
->whereBetween('t.txn_date', [$from, $to])
->whereIn('t.source', $sources)
->where(function ($query) use ($account): void {
$query->where('t.account_short', (string) $account->account_short)
->orWhere('m.account_short', (string) $account->account_short);
})
->orderBy('t.id')
->pluck('t.id')
->map(static fn (mixed $id): int => (int) $id)
->all();
}
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 dateTime(string $value): ?string
{
$value = trim($value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/', $value, $matches) !== 1) {
return null;
}
[, $year, $month, $day, $hour, $minute, $second] = array_map('intval', $matches);
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);
}
private function money(string $value): float
{
$text = trim(str_replace([',', ',', '¥', '¥', ' '], '', $value));
return $text === '' ? 0.0 : round((float) $text, 2);
}
private function header(string $value): string
{
return preg_replace('/[\s ]+/u', '', trim($value)) ?? trim($value);
}
private function text(mixed $value): string
{
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
}
}