294 lines
12 KiB
PHP
294 lines
12 KiB
PHP
<?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);
|
||
}
|
||
}
|