393 lines
15 KiB
PHP
393 lines
15 KiB
PHP
<?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);
|
||
}
|
||
}
|