From fdcbb6ff41b39de209ce480223583709cc1a2d9c Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Fri, 6 Nov 2026 17:09:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=9C=B1=E4=BF=8A?= =?UTF-8?q?=E7=A5=A5=E5=BE=AE=E4=BF=A1=E6=9C=88=E5=BA=A6=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportWechatStatement.php | 51 +++ .../Services/WechatStatementImporter.php | 293 ++++++++++++++++++ .../pages/financial-ledger/ledger.blade.php | 2 +- .../Console/ImportWechatStatementTest.php | 128 ++++++++ 4 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ImportWechatStatement.php create mode 100644 modules/EcommerceFinance/Services/WechatStatementImporter.php create mode 100644 tests/Feature/Console/ImportWechatStatementTest.php diff --git a/app/Console/Commands/ImportWechatStatement.php b/app/Console/Commands/ImportWechatStatement.php new file mode 100644 index 00000000..045188a3 --- /dev/null +++ b/app/Console/Commands/ImportWechatStatement.php @@ -0,0 +1,51 @@ +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; + } +} diff --git a/modules/EcommerceFinance/Services/WechatStatementImporter.php b/modules/EcommerceFinance/Services/WechatStatementImporter.php new file mode 100644 index 00000000..26e75614 --- /dev/null +++ b/modules/EcommerceFinance/Services/WechatStatementImporter.php @@ -0,0 +1,293 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array + */ + 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>} + */ + 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); + } +} diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 4cf1f57b..3998aef4 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -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 ?? []; diff --git a/tests/Feature/Console/ImportWechatStatementTest.php b/tests/Feature/Console/ImportWechatStatementTest.php new file mode 100644 index 00000000..65025c0e --- /dev/null +++ b/tests/Feature/Console/ImportWechatStatementTest.php @@ -0,0 +1,128 @@ +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> $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; + } +} -- 2.47.2