增加朱俊祥微信月度账单导入 #963
51
app/Console/Commands/ImportWechatStatement.php
Normal file
51
app/Console/Commands/ImportWechatStatement.php
Normal 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;
|
||||
}
|
||||
}
|
||||
293
modules/EcommerceFinance/Services/WechatStatementImporter.php
Normal file
293
modules/EcommerceFinance/Services/WechatStatementImporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 ?? [];
|
||||
|
||||
128
tests/Feature/Console/ImportWechatStatementTest.php
Normal file
128
tests/Feature/Console/ImportWechatStatementTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user