接入公司非店铺支付宝账单、受限批处理导入,并为账户总表及每日资金流水增加管理员手动编辑与审计。 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
430 lines
16 KiB
PHP
430 lines
16 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;
|
||
|
||
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);
|
||
}
|
||
}
|