增加朱俊祥微信月度账单导入 #963

Merged
shanshi merged 1 commits from feature/shanshi-wechat-statement-importer into main 2026-11-06 17:11:52 +08:00
4 changed files with 473 additions and 1 deletions

View File

@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\WechatStatementImporter;
use Throwable;
class ImportWechatStatement extends Command
{
protected $signature = 'finance-ledger:import-wechat-statement
{path : 朱俊祥微信月度收支表 XLS/XLSX 文件}
{--account-short= : 独立账单账户简称,默认朱俊祥微信}
{--opening-balance= : 指定期初余额;默认读取文件期初行}
{--dry-run : 只校验,不写入财务台账}';
protected $description = '导入朱俊祥微信月度收支表,保留期初、逐笔收支和余额';
public function handle(WechatStatementImporter $importer): int
{
$opening = trim((string) $this->option('opening-balance'));
try {
$result = $importer->import(
(string) $this->argument('path'),
$this->option('account-short') ?: null,
$opening === '' ? null : (float) $opening,
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
$this->error('微信收支表未导入,保留原有数据。' . $exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['校验模式', $result['dry_run'] ? '仅校验' : '正式导入'],
['账户简称', $result['account_short']],
['日期范围', $result['from'] . ' 至 ' . $result['to']],
['期初余额', number_format((float) $result['opening_balance'], 2)],
['有效笔数', $result['rows']],
['收入合计', number_format((float) $result['income'], 2)],
['支出合计', number_format((float) $result['expense'], 2)],
['期末余额', number_format((float) $result['closing_balance'], 2)],
['替换原记录', $result['replaced']],
]);
$this->info($result['dry_run'] ? '微信收支表校验完成。' : '微信收支表已导入独立账单。');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,293 @@
<?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);
}
}

View File

@ -84,7 +84,7 @@
'tab' => 'transactions',
'txns_view' => $transactionView,
]);
$sourceLabels = ['dingtalk' => '钉钉审批', 'alipay-company' => '公司支付宝', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
$sourceLabels = ['dingtalk' => '钉钉审批', 'alipay-company' => '公司支付宝', 'wechat-statement' => '公司微信', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
$shopBalanceDate = $ledgerShopBalanceDate ?? null;
$shopBalanceDates = $ledgerShopBalanceDates ?? [];
$manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? [];

View File

@ -0,0 +1,128 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class ImportWechatStatementTest extends FeatureTestCase
{
public function test_imports_monthly_wechat_statement_and_replaces_the_same_month(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥微信',
'account_type' => '个人微信',
'current_balance' => 0,
]);
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-03',
'account_short' => '朱俊祥微信',
'detail' => '旧月份记录',
'income' => 1,
'expense' => 0,
'balance' => 101,
'source' => 'wechat-statement',
'source_value' => '朱俊祥微信',
'process_type' => '公司微信收支流水',
'row_key' => 'old-wechat-statement',
]);
$file = $this->workbook([
['日期', '简称', '收入', '支出', '余额'],
[null, '朱俊祥微信', null, null, 100],
['10/4/2026', '朱俊祥微信', 20, null, 120],
['10/5/2026', '朱俊祥微信', null, 5, 115],
]);
$this->artisan('finance-ledger:import-wechat-statement', [
'path' => $file,
'--dry-run' => true,
])->assertExitCode(0);
$this->assertDatabaseHas('fin_transactions', [
'row_key' => 'old-wechat-statement',
], 'business_mirror');
$this->artisan('finance-ledger:import-wechat-statement', [
'path' => $file,
])->assertExitCode(0);
$this->assertDatabaseMissing('fin_transactions', [
'row_key' => 'old-wechat-statement',
], 'business_mirror');
$this->assertSame(2, $connection->table('fin_transactions')
->where('source', 'wechat-statement')
->where('account_short', '朱俊祥微信')
->count());
$this->assertDatabaseHas('fin_transactions', [
'txn_date' => '2026-10-04',
'account_short' => '朱俊祥微信',
'income' => 20,
'balance' => 120,
'source' => 'wechat-statement',
], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'txn_date' => '2026-10-05',
'account_short' => '朱俊祥微信',
'expense' => 5,
'balance' => 115,
], 'business_mirror');
$this->assertDatabaseHas('fin_account_opening', [
'account_short' => '朱俊祥微信',
'opening_date' => '2026-10-01',
'opening_balance' => 100,
], 'business_mirror');
$this->assertDatabaseMissing('fin_daily_flows', [
'account_short' => '朱俊祥微信',
], 'business_mirror');
$this->assertSame(0.0, (float) $connection->table('fin_accounts')
->where('account_short', '朱俊祥微信')
->value('current_balance'));
}
public function test_unbalanced_wechat_statement_keeps_existing_rows(): void
{
DB::connection('business_mirror')->table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥微信',
]);
DB::connection('business_mirror')->table('fin_transactions')->insert([
'txn_date' => '2026-10-03',
'account_short' => '朱俊祥微信',
'detail' => '应保留记录',
'income' => 1,
'expense' => 0,
'balance' => 101,
'source' => 'wechat-statement',
'source_value' => '朱俊祥微信',
'row_key' => 'wechat-unbalanced-existing',
]);
$file = $this->workbook([
['日期', '简称', '收入', '支出', '余额'],
[null, '朱俊祥微信', null, null, 100],
['10/4/2026', '朱俊祥微信', 20, null, 125],
]);
$this->withExceptionHandling()->artisan('finance-ledger:import-wechat-statement', [
'path' => $file,
])->assertExitCode(1);
$this->assertDatabaseHas('fin_transactions', [
'row_key' => 'wechat-unbalanced-existing',
], 'business_mirror');
}
/** @param list<list<mixed>> $rows */
private function workbook(array $rows): string
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray($rows, null, 'A1');
$path = tempnam(sys_get_temp_dir(), 'wechat-statement-') . '.xlsx';
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
return $path;
}
}