CaiWuXiTong/modules/EcommerceFinance/Services/WechatStatementImporter.php

294 lines
12 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 InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* 导入朱俊祥微信月度收支表。
*
* 工作簿使用“日期、简称、收入、支出、余额”五列;首笔余额单独一行、
* 日期为空,作为该月期初。每一笔余额必须按上一笔余额加收支滚动对平。
*/
class WechatStatementImporter
{
public const SOURCE = 'wechat-statement';
public const PROCESS_TYPE = '公司微信收支流水';
public const DEFAULT_ACCOUNT_SHORT = '朱俊祥微信';
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly FinanceLedgerCalculator $calculator,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array<string,mixed>
*/
public function import(
string $path,
?string $accountShort = null,
?float $openingBalance = null,
bool $dryRun = false,
): array {
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('微信收支表文件不存在或不可读取。');
}
$accountShort = trim((string) ($accountShort ?: self::DEFAULT_ACCOUNT_SHORT));
if (! FinanceLedgerIndependentAccounts::contains($accountShort)) {
throw new InvalidArgumentException('微信月度收支表只能导入独立账单账户。');
}
if (! $this->connection->table('fin_accounts')->where('account_short', $accountShort)->exists()) {
throw new InvalidArgumentException('账户总表中没有账户:' . $accountShort);
}
[$fileOpening, $rows] = $this->rows($path, $accountShort);
if ($rows === []) {
throw new InvalidArgumentException('微信收支表中没有有效明细。');
}
if ($openingBalance !== null && abs(round($openingBalance, 2) - $fileOpening) > 0.001) {
throw new InvalidArgumentException(sprintf(
'期初余额不一致:指定 %.2f,文件为 %.2f。',
$openingBalance,
$fileOpening,
));
}
$openingBalance = $openingBalance === null ? $fileOpening : round($openingBalance, 2);
$income = round(array_sum(array_column($rows, 'income')), 2);
$expense = round(array_sum(array_column($rows, 'expense')), 2);
$closingBalance = round((float) $rows[array_key_last($rows)]['balance'], 2);
$expectedClosing = round($openingBalance + $income - $expense, 2);
if (abs($expectedClosing - $closingBalance) > 0.001) {
throw new InvalidArgumentException(sprintf(
'微信收支未对平:期初加收支应为 %.2f,末笔余额为 %.2f。',
$expectedClosing,
$closingBalance,
));
}
$firstDate = (string) $rows[0]['txn_date'];
$lastDate = (string) $rows[array_key_last($rows)]['txn_date'];
$from = CarbonImmutable::parse($firstDate)->startOfMonth()->toDateString();
$to = CarbonImmutable::parse($lastDate)->endOfMonth()->toDateString();
$replaceCount = (int) $this->connection->table('fin_transactions')
->where('source', self::SOURCE)
->where('source_value', $accountShort)
->whereBetween('txn_date', [$from, $to])
->count();
$result = [
'dry_run' => $dryRun,
'account_short' => $accountShort,
'from' => $from,
'to' => $to,
'opening_balance' => $openingBalance,
'closing_balance' => $closingBalance,
'rows' => count($rows),
'income' => $income,
'expense' => $expense,
'replaced' => $replaceCount,
'recalculated' => null,
];
if ($dryRun) {
return $result;
}
$result['recalculated'] = $this->connection->transaction(function () use (
$accountShort,
$from,
$to,
$openingBalance,
$rows,
): array {
$this->connection->table('fin_transactions')
->where('source', self::SOURCE)
->where('source_value', $accountShort)
->whereBetween('txn_date', [$from, $to])
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table('fin_transactions')->insert($chunk);
}
$now = now();
$this->connection->table('fin_account_opening')
->where('account_short', $accountShort)
->delete();
$this->connection->table('fin_account_opening')->insert([
'account_short' => $accountShort,
'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' => $accountShort],
[
'account_short' => $accountShort,
'txn_count' => count($rows),
'income_sum' => round(array_sum(array_column($rows, 'income')), 2),
'expense_sum' => round(array_sum(array_column($rows, 'expense')), 2),
'updated_by' => '系统自动同步',
'updated_at' => $now,
'created_at' => $now,
],
);
return $this->calculator->recalculate();
});
return $result;
}
/**
* @return array{0:float,1:list<array<string,mixed>>}
*/
private function rows(string $path, string $accountShort): array
{
$spreadsheet = IOFactory::load($path);
$matrix = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
$spreadsheet->disconnectWorksheets();
$headerIndex = null;
foreach ($matrix as $index => $row) {
$headers = array_map(fn (mixed $value): string => $this->header((string) $value), $row);
if (in_array('日期', $headers, true)
&& in_array('收入', $headers, true)
&& in_array('支出', $headers, true)
&& in_array('余额', $headers, true)) {
$headerIndex = $index;
break;
}
}
if ($headerIndex === null) {
throw new InvalidArgumentException('未找到微信收支表头:日期、收入、支出、余额。');
}
$headers = array_map(fn (mixed $value): string => $this->header((string) $value), $matrix[$headerIndex]);
$indexes = [];
foreach (['date' => '日期', 'account' => '简称', 'income' => '收入', 'expense' => '支出', 'balance' => '余额'] as $key => $label) {
$position = array_search($label, $headers, true);
if ($position === false) {
throw new InvalidArgumentException('微信收支表缺少字段:' . $label);
}
$indexes[$key] = $position;
}
$opening = null;
$rows = [];
$previousBalance = null;
$occurrences = [];
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $rowIndex => $values) {
$value = static fn (string $key): string => trim((string) ($values[$indexes[$key]] ?? ''));
$balance = $value('balance');
if ($opening === null && $value('date') === '' && $balance !== '') {
$opening = $this->money($balance);
$previousBalance = $opening;
continue;
}
if ($value('date') === '') {
continue;
}
$date = $this->date($value('date'));
$income = $value('income') === '' ? 0.0 : $this->money($value('income'));
$expense = $value('expense') === '' ? 0.0 : $this->money($value('expense'));
if (($income > 0) === ($expense > 0)) {
throw new InvalidArgumentException('微信收支第 ' . ($rowIndex + 1) . ' 行必须且只能填写收入或支出。');
}
if ($balance === '') {
throw new InvalidArgumentException('微信收支第 ' . ($rowIndex + 1) . ' 行缺少余额。');
}
$balanceValue = $this->money($balance);
if ($previousBalance !== null) {
$expected = round($previousBalance + $income - $expense, 2);
if (abs($expected - $balanceValue) > 0.001) {
throw new InvalidArgumentException(sprintf(
'微信收支第 %d 行余额未对平:应为 %.2f,实际 %.2f。',
$rowIndex + 1,
$expected,
$balanceValue,
));
}
}
$previousBalance = $balanceValue;
$occurrenceKey = implode("\0", [$date, $income, $expense, $balanceValue]);
$occurrences[$occurrenceKey] = ($occurrences[$occurrenceKey] ?? 0) + 1;
$now = now();
$rows[] = [
'txn_date' => $date,
'account_short' => $accountShort,
'counterparty_info' => null,
'detail' => '朱俊祥微信收支',
'income' => $income,
'expense' => $expense,
'balance' => $balanceValue,
'approval_no' => 'WX-' . str_replace('-', '', $date) . '-' . str_pad((string) ($rowIndex + 1), 4, '0', STR_PAD_LEFT),
'payment_remark' => null,
'source' => self::SOURCE,
'source_value' => $accountShort,
'process_type' => self::PROCESS_TYPE,
'row_key' => hash('sha256', implode("\0", [
self::SOURCE,
$accountShort,
$occurrenceKey,
$occurrences[$occurrenceKey],
])),
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'created_by' => '系统自动同步',
'updated_by' => '系统自动同步',
];
}
if ($opening === null) {
throw new InvalidArgumentException('微信收支表未找到期初余额行。');
}
return [round($opening, 2), $rows];
}
private function date(string $value): string
{
foreach (['!n/j/Y', '!Y-m-d', '!Y/m/d'] as $format) {
$date = CarbonImmutable::createFromFormat($format, trim($value));
if ($date instanceof CarbonImmutable && $date->format(ltrim($format, '!')) === trim($value)) {
return $date->format('Y-m-d');
}
}
if (is_numeric($value)) {
return \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject((float) $value)
->format('Y-m-d');
}
throw new InvalidArgumentException('微信收支日期格式无法识别:' . $value);
}
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);
}
}