From c6e0cc4266282a25aeb736943a20ce45921ddeb5 Mon Sep 17 00:00:00 2001 From: 92451 <924518135@qq.com> Date: Fri, 30 Oct 2026 19:58:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=85=AC=E5=8F=B8?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=AE=9D=E8=B4=A6=E5=8D=95=E4=B8=8E=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E6=89=8B=E5=8A=A8=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportCompanyAlipayBills.php | 58 +++ .../Controllers/Business/FinanceLedger.php | 143 ++++++ ...eate_finance_ledger_manual_edit_tables.php | 81 ++++ deploy/finance-batch-imports.json | 1 + .../Services/CompanyAlipayBillImporter.php | 429 ++++++++++++++++++ .../Services/FinanceLedgerAccountEditor.php | 205 +++++++++ .../Services/FinanceLedgerCalculator.php | 76 +++- .../Services/FinanceLedgerFlowEditor.php | 207 +++++++++ .../pages/financial-ledger/ledger.blade.php | 340 +++++++++++++- routes/admin.php | 22 + scripts/company-alipay-direct/README.md | 30 ++ .../company-alipay-direct/export-daily.mjs | 208 +++++++++ .../install-daily-task.ps1 | 75 +++ scripts/company-alipay-direct/open-login.mjs | 62 +++ .../run-alipay-daily.ps1 | 99 ++++ scripts/erp-daily-bills/cdp.mjs | 10 +- .../Business/FinanceLedgerManualEditTest.php | 277 +++++++++++ .../Console/ImportCompanyAlipayBillsTest.php | 151 ++++++ 18 files changed, 2464 insertions(+), 10 deletions(-) create mode 100644 app/Console/Commands/ImportCompanyAlipayBills.php create mode 100644 database/migrations/2026_10_30_100000_create_finance_ledger_manual_edit_tables.php create mode 100644 modules/EcommerceFinance/Services/CompanyAlipayBillImporter.php create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerAccountEditor.php create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php create mode 100644 scripts/company-alipay-direct/README.md create mode 100644 scripts/company-alipay-direct/export-daily.mjs create mode 100644 scripts/company-alipay-direct/install-daily-task.ps1 create mode 100644 scripts/company-alipay-direct/open-login.mjs create mode 100644 scripts/company-alipay-direct/run-alipay-daily.ps1 create mode 100644 tests/Feature/Business/FinanceLedgerManualEditTest.php create mode 100644 tests/Feature/Console/ImportCompanyAlipayBillsTest.php diff --git a/app/Console/Commands/ImportCompanyAlipayBills.php b/app/Console/Commands/ImportCompanyAlipayBills.php new file mode 100644 index 00000000..e08ee988 --- /dev/null +++ b/app/Console/Commands/ImportCompanyAlipayBills.php @@ -0,0 +1,58 @@ +option('date') ?: now()->subDay()->toDateString())); + $account = trim((string) $this->option('account')); + if ($account === '') { + $this->error('必须使用 --account 指定支付宝登录账号。'); + + return self::FAILURE; + } + + try { + $result = $importer->import( + (string) $this->argument('path'), + $account, + $date, + $this->option('account-short') ?: null, + (bool) $this->option('dry-run'), + ); + } catch (Throwable $exception) { + $this->error('支付宝账单未导入,保留原有动账明细。' . $exception->getMessage()); + + return self::FAILURE; + } + + $this->table(['项目', '值'], [ + ['校验模式', $result['dry_run'] ? '仅校验' : '正式导入'], + ['账单日期', $result['balance_date']], + ['账户简称', $result['account_short']], + ['有效笔数', $result['rows']], + ['收入合计', number_format((float) $result['income'], 2)], + ['支出合计', number_format((float) $result['expense'], 2)], + ['替换原记录', $result['replaced']], + ['每日流水行数', $result['recalculated']['flows'] ?? '-'], + ]); + $this->info($result['dry_run'] ? '支付宝账单校验完成。' : '支付宝账单已接入财务台账动账明细。'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 9dc5a00e..12f10523 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -15,8 +15,10 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\FinanceLedgerAccountCreator; +use Modules\EcommerceFinance\Services\FinanceLedgerAccountEditor; use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; +use Modules\EcommerceFinance\Services\FinanceLedgerFlowEditor; use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer; use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; @@ -1072,6 +1074,147 @@ class FinanceLedger extends Controller ], $inserted ? 201 : 200, ['Cache-Control' => 'private, no-store']); } + public function editAccount(int $id): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户。'); + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $account = $connection->table('fin_accounts')->where('id', $id)->first(); + abort_if($account === null, 404); + $account->status = FinanceLedgerAccountStatus::normalize( + $connection->table('fin_account_statuses')->where('account_short', $account->account_short)->value('status'), + ); + $overrideType = $connection->table('fin_account_type_overrides') + ->where('account_short', $account->account_short) + ->value('account_type'); + if ($overrideType !== null) { + $account->account_type = (string) $overrideType; + } + + return response()->json([ + 'ok' => true, + 'account' => $account, + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function updateAccount( + Request $request, + int $id, + FinanceLedgerAccountEditor $editor, + ): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户。'); + + $data = $request->validate([ + 'company_name' => ['required', 'string', 'max:100'], + 'account_short' => ['required', 'string', 'max:100'], + 'account_type' => ['nullable', 'string', 'max:50'], + 'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))], + 'region' => ['nullable', 'string', 'max:50'], + 'account_no' => ['nullable', 'string', 'max:50'], + 'bank_name' => ['nullable', 'string', 'max:200'], + 'bank_city_branch' => ['nullable', 'string', 'max:100'], + 'company_plan' => ['nullable', 'string', 'max:50'], + 'account_should_keep' => ['nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS)], + 'single_limit' => ['nullable', 'string', 'max:50'], + 'daily_limit' => ['nullable', 'string', 'max:50'], + 'monthly_limit' => ['nullable', 'string', 'max:50'], + 'yearly_limit' => ['nullable', 'string', 'max:50'], + 'clearing_days' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'], + 'raise_target' => ['nullable', 'string', 'max:50'], + 'raise_status' => ['nullable', 'string', 'max:50'], + 'raise_actions' => ['nullable', 'string', 'max:500'], + 'done_actions' => ['nullable', 'string', 'max:500'], + 'next_action' => ['nullable', 'string', 'max:200'], + 'remark' => ['nullable', 'string', 'max:500'], + 'owner' => ['nullable', 'string', 'max:50'], + ]); + + $accountType = trim((string) ($data['account_type'] ?? '')); + if ($accountType !== '') { + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $allowedTypes = FinanceLedgerAccountType::options( + $connection->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(), + $connection->table('fin_account_type_overrides') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(), + ); + if (! in_array($accountType, $allowedTypes, true)) { + throw ValidationException::withMessages([ + 'account_type' => '请选择现有账户类型。', + ]); + } + } + + $account = $editor->update($id, $data); + $account['status'] = FinanceLedgerAccountStatus::normalize((string) $data['status']); + + return response()->json([ + 'ok' => true, + 'account' => $account, + 'message' => '账户信息已保存。', + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function editDailyFlow(Request $request, FinanceLedgerFlowEditor $editor): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。'); + + $data = $request->validate([ + 'flow_date' => ['required', 'date_format:Y-m-d'], + 'account_short' => ['required', 'string', 'max:100'], + ]); + $flow = $editor->existingFlow((string) $data['flow_date'], trim((string) $data['account_short'])); + abort_if($flow === null, 404); + + return response()->json([ + 'ok' => true, + 'flow' => $flow, + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function updateDailyFlow(Request $request, FinanceLedgerFlowEditor $editor): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。'); + + $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); + $data = $request->validate([ + 'flow_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:' . $openingDate, 'before_or_equal:today'], + 'account_short' => ['required', 'string', 'max:100'], + 'opening_balance' => ['required', 'numeric', 'between:-9999999999999999.99,9999999999999999.99'], + 'inflow' => ['required', 'numeric', 'min:0', 'max:9999999999999999.99'], + 'outflow' => ['required', 'numeric', 'min:0', 'max:9999999999999999.99'], + 'closing_balance' => ['required', 'numeric', 'between:-9999999999999999.99,9999999999999999.99'], + 'remark' => ['nullable', 'string', 'max:200'], + ]); + $flow = $editor->update($data); + + return response()->json([ + 'ok' => true, + 'flow' => $flow, + 'message' => '每日资金流水已保存并重算。', + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function resetDailyFlow(Request $request, FinanceLedgerFlowEditor $editor): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。'); + + $data = $request->validate([ + 'flow_date' => ['required', 'date_format:Y-m-d'], + 'account_short' => ['required', 'string', 'max:100'], + ]); + $flow = $editor->reset((string) $data['flow_date'], trim((string) $data['account_short'])); + + return response()->json([ + 'ok' => true, + 'flow' => $flow, + 'message' => '已恢复自动计算。', + ], 200, ['Cache-Control' => 'private, no-store']); + } + public function updateTransactionAccount(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改动账账户关联。'); diff --git a/database/migrations/2026_10_30_100000_create_finance_ledger_manual_edit_tables.php b/database/migrations/2026_10_30_100000_create_finance_ledger_manual_edit_tables.php new file mode 100644 index 00000000..572a9651 --- /dev/null +++ b/database/migrations/2026_10_30_100000_create_finance_ledger_manual_edit_tables.php @@ -0,0 +1,81 @@ +connection()); + + if ($schema->hasTable('fin_daily_flows') && ! $schema->hasColumn('fin_daily_flows', 'manual_override')) { + $schema->table('fin_daily_flows', function (Blueprint $table): void { + $table->boolean('manual_override')->default(false)->after('remark'); + $table->string('updated_by', 100)->nullable()->after('manual_override'); + }); + } + + if (! $schema->hasTable('fin_daily_flow_overrides')) { + $schema->create('fin_daily_flow_overrides', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->date('flow_date'); + $table->string('account_short', 100); + $table->decimal('opening_balance', 18, 2)->default(0); + $table->decimal('inflow', 18, 2)->default(0); + $table->decimal('outflow', 18, 2)->default(0); + $table->decimal('closing_balance', 18, 2)->default(0); + $table->string('remark', 200)->nullable(); + $table->string('updated_by', 100)->nullable(); + $table->timestamps(); + + $table->unique( + ['flow_date', 'account_short'], + 'fin_flow_override_date_account_unique', + ); + $table->index(['account_short'], 'fin_flow_override_account_index'); + }); + } + + if (! $schema->hasTable('fin_ledger_edit_events')) { + $schema->create('fin_ledger_edit_events', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('entity_type', 20); + $table->string('entity_key', 200); + $table->json('changes'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('user_name', 100)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index( + ['entity_type', 'entity_key', 'created_at'], + 'fin_edit_events_entity_index', + ); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('fin_ledger_edit_events'); + $schema->dropIfExists('fin_daily_flow_overrides'); + + if ($schema->hasTable('fin_daily_flows') && $schema->hasColumn('fin_daily_flows', 'updated_by')) { + $schema->table('fin_daily_flows', function (Blueprint $table): void { + $table->dropColumn('updated_by'); + }); + } + if ($schema->hasTable('fin_daily_flows') && $schema->hasColumn('fin_daily_flows', 'manual_override')) { + $schema->table('fin_daily_flows', function (Blueprint $table): void { + $table->dropColumn('manual_override'); + }); + } + } +}; diff --git a/deploy/finance-batch-imports.json b/deploy/finance-batch-imports.json index 71b2efec..b1ac2a98 100644 --- a/deploy/finance-batch-imports.json +++ b/deploy/finance-batch-imports.json @@ -9,6 +9,7 @@ "courier-quotes:import-billing-rules", "dewu-bills:import-authoritative", "business-mirror:import-douyin-authoritative", + "finance-ledger:import-company-alipay", "finance-ledger:import-base", "finance-ledger:import-snapshot", "internal-flow:import", diff --git a/modules/EcommerceFinance/Services/CompanyAlipayBillImporter.php b/modules/EcommerceFinance/Services/CompanyAlipayBillImporter.php new file mode 100644 index 00000000..c600a7ec --- /dev/null +++ b/modules/EcommerceFinance/Services/CompanyAlipayBillImporter.php @@ -0,0 +1,429 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{ + * dry_run:bool,balance_date:string,account_short:string,rows:int, + * income:float,expense:float,replaced:int,recalculated:?array + * } + */ + public function import( + string $path, + string $accountNo, + string $balanceDate, + ?string $accountShort = null, + bool $dryRun = false, + ): array { + $date = $this->date($balanceDate); + $accountNo = $this->text($accountNo); + if ($accountNo === '') { + throw new InvalidArgumentException('支付宝账号不能为空。'); + } + if (! is_file($path) || ! is_readable($path)) { + throw new InvalidArgumentException('支付宝账单文件不存在或不可读取。'); + } + + $account = $this->account($accountNo, $accountShort); + $rows = $this->rows($path, $accountNo, $account->account_short, $date); + if ($rows === []) { + throw new InvalidArgumentException("支付宝账单中没有 {$date} 的有效交易,已保留原有动账明细。"); + } + + $income = round(array_sum(array_column($rows, 'income')), 2); + $expense = round(array_sum(array_column($rows, 'expense')), 2); + $replaced = $this->connection->table('fin_transactions') + ->where('source', self::SOURCE) + ->where('source_value', $accountNo) + ->whereDate('txn_date', $date) + ->count(); + $result = [ + 'dry_run' => $dryRun, + 'balance_date' => $date, + 'account_short' => (string) $account->account_short, + 'rows' => count($rows), + 'income' => $income, + 'expense' => $expense, + 'replaced' => $replaced, + 'recalculated' => null, + ]; + if ($dryRun) { + return $result; + } + + $recalculated = $this->connection->transaction(function () use ( + $rows, + $date, + $accountNo, + $account, + $income, + $expense, + ): array { + $this->connection->table('fin_transactions') + ->where('source', self::SOURCE) + ->where('source_value', $accountNo) + ->whereDate('txn_date', $date) + ->delete(); + foreach (array_chunk($rows, 500) as $chunk) { + $this->connection->table('fin_transactions')->insert($chunk); + } + + $now = now(); + $this->connection->table('fin_account_map')->updateOrInsert( + ['source' => self::SOURCE, 'source_value' => $accountNo], + [ + 'account_short' => (string) $account->account_short, + 'txn_count' => count($rows), + 'income_sum' => $income, + 'expense_sum' => $expense, + 'updated_by' => '系统自动同步', + 'updated_at' => $now, + 'created_at' => $now, + ], + ); + + return app(FinanceLedgerCalculator::class)->recalculate(); + }); + + $result['recalculated'] = $recalculated; + + return $result; + } + + private function account(string $accountNo, ?string $accountShort): object + { + $query = $this->connection->table('fin_accounts')->where('account_no', $accountNo); + $accountShort = $this->text($accountShort); + if ($accountShort !== '') { + $query->where('account_short', $accountShort); + } + $accounts = $query->get(['id', 'account_short', 'account_no', 'account_type']); + if ($accounts->isEmpty()) { + throw new InvalidArgumentException('账户总表中没有账号为 ' . $accountNo . ' 的支付宝账户。'); + } + if ($accounts->count() > 1) { + throw new InvalidArgumentException('支付宝账号对应多个账户,请同时指定账户简称。'); + } + + return $accounts->first(); + } + + /** @return list> */ + private function rows(string $path, string $accountNo, string $accountShort, string $date): array + { + $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); + [$headers, $dataRows] = match ($extension) { + 'csv' => $this->csvRows($path), + 'xlsx', 'xls' => $this->workbookRows($path), + default => throw new InvalidArgumentException('支付宝账单仅支持 CSV、XLSX 或 XLS 文件。'), + }; + $fields = $this->fields($headers); + $rows = []; + $seen = []; + + foreach ($dataRows as $values) { + $value = static function (string $field) use ($values, $fields): string { + $index = $fields[$field] ?? null; + + return $index === null ? '' : trim((string) ($values[$index] ?? '')); + }; + $time = $this->dateTime($value('time')); + if ($time === null || substr($time, 0, 10) !== $date) { + continue; + } + $status = $value('status'); + if ($status !== '' && preg_match('/关闭|失败|等待付款|未支付|取消/u', $status) === 1) { + continue; + } + + $rawAmount = $this->amountText($value('amount')); + $direction = $value('direction'); + [$income, $expense] = $this->amounts($rawAmount, $direction); + if ($income === 0.0 && $expense === 0.0) { + continue; + } + + $recordCode = $value('record_code'); + if ($recordCode === '') { + $recordCode = hash('sha256', implode("\0", [ + $time, $direction, $rawAmount, $value('counterparty'), $value('description'), $value('merchant_order_no'), + ])); + } + $uniqueKey = $recordCode . "\0" . $time . "\0" . number_format($income, 2, '.', '') . "\0" . number_format($expense, 2, '.', ''); + if (isset($seen[$uniqueKey])) { + continue; + } + $seen[$uniqueKey] = true; + + $counterparty = $value('counterparty'); + $description = $value('description'); + $paymentMethod = $value('payment_method'); + $merchantOrderNo = $value('merchant_order_no'); + $remark = $value('remark'); + $detail = implode(' · ', array_values(array_filter([ + $counterparty, + $description, + $remark, + ], static fn (string $text): bool => $text !== ''))); + if ($detail === '') { + $detail = '支付宝交易 ' . $recordCode; + } + + $now = now(); + $rows[] = [ + 'txn_date' => $date, + 'account_short' => $accountShort, + 'detail' => mb_substr($detail, 0, 4000), + 'income' => $income, + 'expense' => $expense, + 'approval_no' => mb_substr($recordCode, 0, 100), + 'source' => self::SOURCE, + 'source_value' => $accountNo, + 'process_type' => self::PROCESS_TYPE, + 'row_key' => hash('sha256', implode("\0", [ + self::SOURCE, $accountNo, $recordCode, $time, $merchantOrderNo, + ])), + 'synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + 'created_by' => '系统自动同步', + 'updated_by' => '系统自动同步', + ]; + } + + return $rows; + } + + /** + * @return array{0:list,1:list>} + */ + private function csvRows(string $path): array + { + [$headers, $headerRow] = $this->csvHeader($path); + + return [$headers, (function () use ($path, $headerRow): \Generator { + $stream = $this->openCsvStream($path); + $foundHeader = false; + try { + while (($row = fgetcsv($stream)) !== false) { + $values = array_map(static fn (mixed $value): string => trim((string) $value), $row); + if (! $foundHeader) { + $foundHeader = $values === $headerRow; + continue; + } + + yield $values; + } + } finally { + fclose($stream); + } + })()]; + } + + /** + * @return array{0:list,1:list} + */ + private function csvHeader(string $path): array + { + $stream = $this->openCsvStream($path); + try { + while (($row = fgetcsv($stream)) !== false) { + $values = array_map(static fn (mixed $value): string => trim((string) $value), $row); + $headers = array_map(fn (string $value): string => $this->header($value), $values); + if ($this->hasAny($headers, ['交易时间', '发生时间', '交易创建时间', '创建时间', '付款时间']) + && $this->hasAny($headers, ['金额', '金额元', '金额(元)', '金额(元)', '交易金额'])) { + return [$headers, $values]; + } + } + } finally { + fclose($stream); + } + + throw new InvalidArgumentException('未找到支付宝交易时间与金额表头。'); + } + + /** + * @return resource + */ + private function openCsvStream(string $path) + { + $stream = fopen($path, 'rb'); + if ($stream === false) { + throw new RuntimeException('无法读取支付宝 CSV 文件。'); + } + + $sample = fread($stream, 8192); + if ($sample === false || trim($sample) === '') { + fclose($stream); + throw new InvalidArgumentException('支付宝 CSV 文件为空。'); + } + $hasBom = str_starts_with($sample, "\xEF\xBB\xBF"); + if (! mb_check_encoding($hasBom ? substr($sample, 3) : $sample, 'UTF-8')) { + stream_filter_append($stream, 'convert.iconv.GB18030/UTF-8', STREAM_FILTER_READ); + } + rewind($stream); + if ($hasBom) { + fseek($stream, 3); + } + + return $stream; + } + + /** + * @return array{0:list,1:list>} + */ + private function workbookRows(string $path): array + { + $spreadsheet = IOFactory::load($path); + $matrix = $spreadsheet->getActiveSheet()->toArray(null, true, true, false); + $spreadsheet->disconnectWorksheets(); + + return $this->headerAndRows(array_map( + static fn (array $row): array => array_map(static fn (mixed $value): string => trim((string) $value), $row), + $matrix, + )); + } + + /** + * @param list> $matrix + * @return array{0:list,1:list>} + */ + private function headerAndRows(array $matrix): array + { + foreach ($matrix as $index => $row) { + $headers = array_map(fn (string $value): string => $this->header($value), $row); + $hasTime = $this->hasAny($headers, ['交易时间', '发生时间', '交易创建时间', '创建时间', '付款时间']); + $hasAmount = $this->hasAny($headers, ['金额', '金额元', '金额(元)', '金额(元)', '交易金额']); + if ($hasTime && $hasAmount) { + return [$headers, array_values(array_slice($matrix, $index + 1))]; + } + } + + throw new InvalidArgumentException('未找到支付宝交易时间与金额表头。'); + } + + /** @param list $headers @return array */ + private function fields(array $headers): array + { + $aliases = [ + 'time' => ['交易时间', '发生时间', '交易创建时间', '创建时间', '付款时间'], + 'record_code' => ['交易号', '交易订单号', '支付宝交易号', '流水号'], + 'direction' => ['收/支', '收支', '收支方向', '收入支出', '收入支出类型'], + 'amount' => ['金额', '金额元', '金额(元)', '金额(元)', '交易金额'], + 'counterparty' => ['交易对方', '对方账号', '交易对象'], + 'description' => ['商品说明', '商品名称', '商品'], + 'status' => ['交易状态', '状态'], + 'payment_method' => ['收付款方式', '付款方式'], + 'merchant_order_no' => ['商家订单号', '商户订单号'], + 'remark' => ['备注'], + ]; + $fields = []; + foreach ($aliases as $field => $names) { + foreach ($names as $name) { + $index = array_search($name, $headers, true); + if ($index !== false) { + $fields[$field] = $index; + break; + } + } + } + if (! isset($fields['time'], $fields['record_code'], $fields['direction'], $fields['amount'], $fields['status'])) { + throw new InvalidArgumentException('支付宝账单缺少交易时间、交易号、收支方向、金额或交易状态列。'); + } + + return $fields; + } + + /** @return array{0:float,1:float} */ + private function amounts(string $amount, string $direction): array + { + $negative = str_starts_with($amount, '-'); + $value = round(abs((float) str_replace([',', ',', '¥', '¥'], '', $amount)), 2); + $direction = mb_strtolower($this->text($direction)); + if (str_contains($direction, '不计') || str_contains($direction, '中性')) { + return [0.0, 0.0]; + } + if (str_contains($direction, '支') || str_contains($direction, '出') || $negative) { + return [0.0, $value]; + } + + return [$value, 0.0]; + } + + private function amountText(string $value): string + { + return trim(str_replace([' ', ','], '', $value)); + } + + private function dateTime(string $value): ?string + { + $value = trim($value); + if (is_numeric($value)) { + try { + return ExcelDate::excelToDateTimeObject((float) $value) + ->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } + if (preg_match('/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $matches) !== 1) { + return null; + } + [$year, $month, $day, $hour, $minute] = array_map('intval', array_slice($matches, 1, 5)); + $second = isset($matches[6]) && $matches[6] !== '' ? (int) $matches[6] : 0; + if (! checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) { + return null; + } + + return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $year, $month, $day, $hour, $minute, $second); + } + + /** @param list $headers @param list $names */ + private function hasAny(array $headers, array $names): bool + { + return array_intersect($headers, $names) !== []; + } + + private function header(string $value): string + { + return preg_replace('/[\s ]+/u', '', trim($value)) ?? trim($value); + } + + private function date(string $value): string + { + $date = CarbonImmutable::createFromFormat('!Y-m-d', trim($value)); + if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== trim($value)) { + throw new InvalidArgumentException('账单日期必须使用 YYYY-MM-DD 格式。'); + } + + return $date->format('Y-m-d'); + } + + private function text(mixed $value): string + { + return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value); + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountEditor.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountEditor.php new file mode 100644 index 00000000..40126fc6 --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountEditor.php @@ -0,0 +1,205 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @param array $data + * @return array + */ + public function update(int $id, array $data): array + { + return $this->connection->transaction(function () use ($id, $data): array { + $account = $this->connection->table('fin_accounts')->where('id', $id)->lockForUpdate()->first(); + if ($account === null) { + abort(404); + } + + $oldShort = trim((string) $account->account_short); + $accountShort = $this->text($data['account_short'] ?? ''); + $companyName = $this->text($data['company_name'] ?? ''); + if ($accountShort === '' || $companyName === '') { + throw ValidationException::withMessages([ + 'account_short' => '账户简称和公司名称不能为空。', + ]); + } + + $duplicate = $this->connection->table('fin_accounts') + ->where('account_short', $accountShort) + ->where('id', '<>', $id) + ->exists(); + if ($duplicate) { + throw ValidationException::withMessages([ + 'account_short' => '账户简称已存在,请改用其他简称。', + ]); + } + + $accountType = $this->nullableText($data['account_type'] ?? null); + $accountNo = $this->nullableText($data['account_no'] ?? null); + $status = FinanceLedgerAccountStatus::normalize((string) ($data['status'] ?? '')); + $accountLast4 = FinanceLedgerAccountType::last4( + (string) $accountType, + (string) $accountNo, + (string) ($account->account_last4 ?? ''), + ); + $values = [ + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'region' => $this->nullableText($data['region'] ?? null), + 'account_no' => $accountNo, + 'bank_name' => $this->nullableText($data['bank_name'] ?? null), + 'bank_city_branch' => $this->nullableText($data['bank_city_branch'] ?? null), + 'account_type' => $accountType, + 'company_plan' => $this->nullableText($data['company_plan'] ?? null), + 'account_should_keep' => $this->nullableText($data['account_should_keep'] ?? null), + 'account_last4' => $accountLast4, + 'single_limit' => $this->nullableText($data['single_limit'] ?? null), + 'daily_limit' => $this->nullableText($data['daily_limit'] ?? null), + 'monthly_limit' => $this->nullableText($data['monthly_limit'] ?? null), + 'yearly_limit' => $this->nullableText($data['yearly_limit'] ?? null), + 'clearing_days' => $this->nullableNumber($data['clearing_days'] ?? null), + 'raise_target' => $this->nullableText($data['raise_target'] ?? null), + 'raise_status' => $this->nullableText($data['raise_status'] ?? null), + 'raise_actions' => $this->nullableText($data['raise_actions'] ?? null), + 'done_actions' => $this->nullableText($data['done_actions'] ?? null), + 'next_action' => $this->nullableText($data['next_action'] ?? null), + 'remark' => $this->nullableText($data['remark'] ?? null), + 'owner' => $this->nullableText($data['owner'] ?? null), + 'updated_at' => now(), + ]; + + $before = (array) $account; + if ($oldShort !== $accountShort) { + $this->renameReferences($oldShort, $accountShort); + } + + $this->connection->table('fin_accounts')->where('id', $id)->update($values); + $this->upsertStatus($accountShort, $status); + $this->connection->table('fin_account_type_overrides') + ->where('account_short', $accountShort) + ->delete(); + $this->connection->table('fin_daily_flows') + ->where('account_short', $accountShort) + ->update([ + 'company_name' => $companyName, + 'updated_at' => now(), + ]); + + $this->calculator->refreshAccountAmounts($accountShort); + + $after = (array) $this->connection->table('fin_accounts')->where('id', $id)->first(); + $this->connection->table('fin_ledger_edit_events')->insert([ + 'entity_type' => 'account', + 'entity_key' => (string) $id, + 'changes' => json_encode([ + 'before' => $before, + 'after' => $after, + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'user_id' => user()?->id, + 'user_name' => (string) (user()?->name ?? ''), + 'created_at' => now(), + ]); + + return $after; + }); + } + + private function upsertStatus(string $accountShort, string $status): void + { + $values = [ + 'status' => $status, + 'updated_by' => (string) (user()?->name ?? ''), + 'updated_at' => now(), + ]; + if ($this->connection->table('fin_account_statuses')->where('account_short', $accountShort)->exists()) { + $this->connection->table('fin_account_statuses')->where('account_short', $accountShort)->update($values); + + return; + } + + $this->connection->table('fin_account_statuses')->insert($values + [ + 'account_short' => $accountShort, + 'created_at' => now(), + ]); + } + + private function renameReferences(string $oldShort, string $newShort): void + { + $now = now(); + foreach ([ + 'fin_account_opening' => ['account_short' => [], 'updated_at' => $now], + 'fin_daily_flows' => ['account_short' => [], 'updated_at' => $now], + 'fin_transactions' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now], + 'fin_account_map' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now], + 'fin_account_statuses' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now], + 'fin_account_type_overrides' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now], + 'fin_account_todos' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now], + 'fin_account_keep_todos' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now], + 'fin_shops' => ['account_short' => [], 'updated_at' => $now], + ] as $table => $extra) { + if (! Schema::connection($this->connection->getName())->hasTable($table) + || ! Schema::connection($this->connection->getName())->hasColumn($table, 'account_short')) { + continue; + } + $this->connection->table($table)->where('account_short', $oldShort)->update([ + 'account_short' => $newShort, + ...$extra, + ]); + } + + if (Schema::connection($this->connection->getName())->hasTable('fin_daily_flow_overrides')) { + $this->connection->table('fin_daily_flow_overrides') + ->where('account_short', $oldShort) + ->update([ + 'account_short' => $newShort, + 'updated_at' => $now, + ]); + } + $this->connection->table('fin_account_map_events') + ->where('old_account_short', $oldShort) + ->update(['old_account_short' => $newShort]); + $this->connection->table('fin_account_map_events') + ->where('new_account_short', $oldShort) + ->update(['new_account_short' => $newShort]); + } + + private function text(mixed $value): string + { + return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value); + } + + private function nullableText(mixed $value): ?string + { + $text = $this->text($value); + + return $text !== '' ? $text : null; + } + + private function nullableNumber(mixed $value): ?float + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + return round((float) $value, 2); + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php index c540bd27..8ceac95f 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php @@ -50,6 +50,15 @@ class FinanceLedgerCalculator ->orderBy('txn_date') ->orderBy('id') ->get(['txn_date', 'account_short', 'source', 'source_value', 'income', 'expense']); + $schema = $connection->getSchemaBuilder(); + $manualOverrides = $schema->hasTable('fin_daily_flow_overrides') + ? $connection->table('fin_daily_flow_overrides')->get()->keyBy( + static fn (object $row): string => substr((string) $row->flow_date, 0, 10) + . "\0" + . (string) $row->account_short, + ) + : collect(); + $hasManualColumns = $schema->hasColumn('fin_daily_flows', 'manual_override'); $dailyByAccount = []; $balances = []; @@ -100,11 +109,65 @@ class FinanceLedgerCalculator 'created_at' => $now, 'updated_at' => $now, ]; + if ($hasManualColumns) { + $openingFlow['manual_override'] = false; + $openingFlow['updated_by'] = null; + } $flows[] = $openingFlow; $openingIndex = array_key_last($flows); + $dates = array_values(array_unique([ + ...array_column($rows, 'flow_date'), + ...$manualOverrides + ->filter(static fn (object $row): bool => (string) $row->account_short === $account) + ->map(static fn (object $row): string => substr((string) $row->flow_date, 0, 10)) + ->values() + ->all(), + ])); + sort($dates); + $rowsByDate = []; foreach ($rows as $row) { - if ($row['flow_date'] < $start) { + $rowsByDate[(string) $row['flow_date']] = $row; + } + + foreach ($dates as $date) { + if ($date < $start) { + continue; + } + + $override = $manualOverrides->get($date . "\0" . $account); + if ($override !== null) { + $openingBalance = round((float) $override->opening_balance, 2); + $inflow = round((float) $override->inflow, 2); + $outflow = round((float) $override->outflow, 2); + $balance = round((float) $override->closing_balance, 2); + $flow = [ + 'flow_date' => $date, + 'company_name' => $company, + 'account_short' => $account, + 'opening_balance' => $openingBalance, + 'inflow' => $inflow, + 'outflow' => $outflow, + 'closing_balance' => $balance, + 'remark' => $override->remark !== null ? (string) $override->remark : '手动调整', + 'created_at' => $now, + 'updated_at' => $now, + ]; + if ($hasManualColumns) { + $flow['manual_override'] = true; + $flow['updated_by'] = $override->updated_by !== null ? (string) $override->updated_by : null; + } + + if ($date === $start) { + $flows[$openingIndex] = $flow; + } else { + $flows[] = $flow; + } + continue; + } + + $row = $rowsByDate[$date] ?? null; + if ($row === null) { continue; } $inflow = round((float) $row['inflow'], 2); @@ -113,7 +176,7 @@ class FinanceLedgerCalculator continue; } - if ($row['flow_date'] === $start) { + if ($date === $start) { // 期初日发生动账时合并到同一行,避免唯一键冲突。 $balance = round($balance + $inflow - $outflow, 2); $flows[$openingIndex]['inflow'] = $inflow; @@ -124,8 +187,8 @@ class FinanceLedgerCalculator } $balance = round($balance + $inflow - $outflow, 2); - $flows[] = [ - 'flow_date' => $row['flow_date'], + $flow = [ + 'flow_date' => $date, 'company_name' => $company, 'account_short' => $account, 'opening_balance' => round($balance - $inflow + $outflow, 2), @@ -136,6 +199,11 @@ class FinanceLedgerCalculator 'created_at' => $now, 'updated_at' => $now, ]; + if ($hasManualColumns) { + $flow['manual_override'] = false; + $flow['updated_by'] = null; + } + $flows[] = $flow; } $latest[$account] = $balance; diff --git a/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php b/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php new file mode 100644 index 00000000..e6acedbc --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php @@ -0,0 +1,207 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @param array $data + * @return array + */ + public function update(array $data): array + { + $date = $this->date($data['flow_date'] ?? null); + $accountShort = $this->text($data['account_short'] ?? ''); + $openingCents = $this->cents($data['opening_balance'] ?? 0); + $inflowCents = $this->cents($data['inflow'] ?? 0); + $outflowCents = $this->cents($data['outflow'] ?? 0); + $closingCents = $this->cents($data['closing_balance'] ?? 0); + $opening = $openingCents / 100; + $inflow = $inflowCents / 100; + $outflow = $outflowCents / 100; + $closing = $closingCents / 100; + $remark = $this->nullableText($data['remark'] ?? null); + + if ($openingCents + $inflowCents - $outflowCents !== $closingCents) { + throw ValidationException::withMessages([ + 'closing_balance' => '期末余额必须等于期初余额 + 当日入账 - 当日出账。', + ]); + } + + $this->connection->transaction(function () use ( + $date, + $accountShort, + $opening, + $inflow, + $outflow, + $closing, + $remark, + ): void { + $account = $this->connection->table('fin_accounts') + ->where('account_short', $accountShort) + ->lockForUpdate() + ->first(); + if ($account === null) { + throw ValidationException::withMessages([ + 'account_short' => '账户简称不存在,请从账户总表中选择。', + ]); + } + + $before = $this->existingFlow($date, $accountShort); + $values = [ + 'opening_balance' => $opening, + 'inflow' => $inflow, + 'outflow' => $outflow, + 'closing_balance' => $closing, + 'remark' => $remark, + 'updated_by' => (string) (user()?->name ?? ''), + 'updated_at' => now(), + ]; + $existing = $this->connection->table('fin_daily_flow_overrides') + ->where('flow_date', $date) + ->where('account_short', $accountShort) + ->first(); + if ($existing === null) { + $this->connection->table('fin_daily_flow_overrides')->insert($values + [ + 'flow_date' => $date, + 'account_short' => $accountShort, + 'created_at' => now(), + ]); + } else { + $this->connection->table('fin_daily_flow_overrides') + ->where('id', $existing->id) + ->update($values); + } + + $this->connection->table('fin_ledger_edit_events')->insert([ + 'entity_type' => 'daily_flow', + 'entity_key' => $date . '|' . $accountShort, + 'changes' => json_encode([ + 'before' => $before, + 'after' => [ + 'flow_date' => $date, + 'account_short' => $accountShort, + 'opening_balance' => $opening, + 'inflow' => $inflow, + 'outflow' => $outflow, + 'closing_balance' => $closing, + 'remark' => $remark, + ], + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'user_id' => user()?->id, + 'user_name' => (string) (user()?->name ?? ''), + 'created_at' => now(), + ]); + + $this->calculator->recalculate(); + }); + + return $this->existingFlow($date, $accountShort) ?? []; + } + + /** + * @return array|null + */ + public function reset(string $flowDate, string $accountShort): ?array + { + $date = $this->date($flowDate); + $accountShort = $this->text($accountShort); + + $this->connection->transaction(function () use ($date, $accountShort): void { + $before = $this->existingFlow($date, $accountShort); + $deleted = $this->connection->table('fin_daily_flow_overrides') + ->where('flow_date', $date) + ->where('account_short', $accountShort) + ->delete(); + if ($deleted > 0) { + $this->connection->table('fin_ledger_edit_events')->insert([ + 'entity_type' => 'daily_flow_reset', + 'entity_key' => $date . '|' . $accountShort, + 'changes' => json_encode([ + 'before' => $before, + 'after' => null, + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'user_id' => user()?->id, + 'user_name' => (string) (user()?->name ?? ''), + 'created_at' => now(), + ]); + } + + $this->calculator->recalculate(); + }); + + return $this->existingFlow($date, $accountShort); + } + + /** @return array|null */ + public function existingFlow(string $date, string $accountShort): ?array + { + $row = $this->connection->table('fin_daily_flows') + ->whereDate('flow_date', $date) + ->where('account_short', $accountShort) + ->first(); + if ($row === null) { + return null; + } + + return [ + 'id' => (int) $row->id, + 'flow_date' => substr((string) $row->flow_date, 0, 10), + 'company_name' => $row->company_name, + 'account_short' => $row->account_short, + 'opening_balance' => round((float) $row->opening_balance, 2), + 'inflow' => round((float) $row->inflow, 2), + 'outflow' => round((float) $row->outflow, 2), + 'closing_balance' => round((float) $row->closing_balance, 2), + 'remark' => $row->remark, + 'manual_override' => (bool) ($row->manual_override ?? false), + 'updated_by' => $row->updated_by ?? null, + ]; + } + + private function date(mixed $value): string + { + $text = trim((string) $value); + $date = CarbonImmutable::createFromFormat('!Y-m-d', $text); + if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== $text) { + throw ValidationException::withMessages([ + 'flow_date' => '流水日期必须使用 YYYY-MM-DD 格式。', + ]); + } + + return $text; + } + + private function cents(mixed $value): int + { + return (int) round((float) $value * 100); + } + + private function text(mixed $value): string + { + return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value); + } + + private function nullableText(mixed $value): ?string + { + $text = $this->text($value); + + return $text !== '' ? $text : null; + } +} diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 55ed8f6a..e7df8b09 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -61,7 +61,7 @@ $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $ledgerPageUrl = route('business.pages.show', ['section' => 'financial-operations', 'page' => 'ledger']); $tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]); - $sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入']; + $sourceLabels = ['dingtalk' => '钉钉审批', 'alipay-company' => '公司支付宝', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入']; $shopBalanceDate = $ledgerShopBalanceDate ?? null; $shopBalanceDates = $ledgerShopBalanceDates ?? []; $manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? []; @@ -242,6 +242,9 @@ .ledger-company-form__message[hidden] { display:none !important; } .ledger-company-form__footer { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; padding:.8rem 1.1rem 1rem; border-top:1px solid #e8e3da; } .ledger-company-form__footer .ledger-btn:disabled { opacity:.58; cursor:wait; } + .ledger-edit-dialog { width:min(68rem, calc(100vw - 2rem)); } + .ledger-edit-dialog .ledger-company-form__body { grid-template-columns:repeat(4, minmax(0, 1fr)); } + .ledger-edit-dialog .ledger-edit-field--wide { grid-column:span 2; } #ledger-panel-accounts .ledger-account-editor { display:none; } #ledger-panel-accounts.is-account-edit-mode .ledger-account-readonly { display:none; } #ledger-panel-accounts.is-account-edit-mode .ledger-account-editor { display:block; } @@ -452,8 +455,15 @@ .ledger-source-tag--receiving { background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); } .ledger-balance-alert { display:inline-flex; align-items:center; gap:.2rem; color:var(--vip-warning, #935F07); font-size:.72rem; font-weight:600; white-space:nowrap; } .ledger-balance-alert .material-icons-outlined { font-size:.95rem; } + .ledger-row-edit { display:inline-grid; place-items:center; width:1.55rem; height:1.55rem; margin-left:.3rem; padding:0; border:1px solid var(--vip-border, #D8D2C7); border-radius:.3rem; background:#fff; color:var(--vip-content-accent-dark, #856632); cursor:pointer; vertical-align:middle; } + .ledger-row-edit:hover, .ledger-row-edit:focus-visible { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-soft, #F7F0DE); outline:none; } + .ledger-row-edit .material-icons-outlined { font-size:.95rem; } + .ledger-manual-badge { display:inline-flex; margin-left:.25rem; padding:.08rem .28rem; border-radius:.25rem; background:#fff8eb; color:var(--vip-warning, #935F07); font-size:.58rem; font-weight:700; vertical-align:middle; } + .ledger-flow-equation { grid-column:1 / -1; margin:0; padding:.55rem .65rem; border-radius:.4rem; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-muted, #595959); font-size:.66rem; line-height:1.5; } + .ledger-flow-edit-actions { display:flex; align-items:center; gap:.5rem; margin-right:auto; } + .ledger-flow-edit-actions .ledger-btn { color:var(--vip-warning, #935F07); } .ledger-mono { font-variant-numeric:tabular-nums; } - @media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } } + @media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } .ledger-edit-dialog .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } } @media (max-width: 760px) { .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } .ledger-company-field--wide { grid-column:span 2; } } @media (max-width: 520px) { .ledger-company-form__body { grid-template-columns:minmax(0, 1fr); } .ledger-company-field--wide { grid-column:auto; } } @@ -653,6 +663,86 @@ @endforeach + +
+ @csrf +
+
+

编辑账户

+

可修改账户资料、限额和执行信息。当前余额、可用金额由动账明细和账户状态自动计算,不在此处直接覆盖。

+
+ +
+
+
基础资料账户简称修改后,期初、动账、流水和映射会同步更新。
+
+
+
+
+
+
+
+
+
+
+
+
+ +
限额与出清限额沿用原表字段,可填写数值或范围。
+
+
+
+
+
+ +
提额与备注用于账户管理和风险跟进。
+
+
+
+
+
+
+ +
+
+ + +
+
+
+ +
+ @csrf + + +
+
+

编辑每日资金流水

+

保存后按手填数据重算该账户后续流水和当前余额;恢复自动计算会删除本日手动覆盖。

+
+ +
+
+
+
+
+
+
+
+
+

期末余额按“期初余额 + 当日入账 - 当日出账”自动计算,保存时会再次校验。

+
+ +
+
+
+ + +
+
+
@endif
账户数{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}
@@ -725,7 +815,12 @@ @forelse ($ledgerAccounts as $row) {{ $row->company_name }} - {{ $row->account_short }} + + {{ $row->account_short }} + @if ($canEditAccounts) + + @endif + @if ($canEditAccountType) @@ -860,7 +955,7 @@ ]) @endforeach - @forelse ($ledgerFlows as $row){{ $date($row->flow_date) }}{{ $row->company_name }}{{ $row->account_short }}{{ $money($row->opening_balance) }}{{ $row->inflow > 0 ? $money($row->inflow) : '' }}{{ $row->outflow > 0 ? $money($row->outflow) : '' }}{{ $money($row->closing_balance) }}{{ $row->remark }}@empty
当前筛选没有流水数据;期初日会为每个期初账户生成一行。
@endforelse + @forelse ($ledgerFlows as $row){{ $date($row->flow_date) }}{{ $row->company_name }}{{ $row->account_short }}@if ($row->manual_override ?? false)手动@endif @if ($canEditAccounts)@endif{{ $money($row->opening_balance) }}{{ $row->inflow > 0 ? $money($row->inflow) : '' }}{{ $row->outflow > 0 ? $money($row->outflow) : '' }}{{ $money($row->closing_balance) }}{{ $row->remark }}@empty
当前筛选没有流水数据;期初日会为每个期初账户生成一行。
@endforelse @include('business.pages.financial-ledger.partials.pagination', [ 'rows' => $ledgerFlows, @@ -1695,6 +1790,243 @@ accountCompanyForm.removeAttribute('aria-busy'); } }); + + const accountEditDialog = page.querySelector('[data-ledger-account-dialog]'); + const accountEditForm = page.querySelector('[data-ledger-account-form]'); + const accountEditError = page.querySelector('[data-ledger-account-error]'); + const accountEditSave = page.querySelector('[data-ledger-account-save]'); + const closeAccountEditDialog = () => { + if (accountEditDialog?.open) accountEditDialog.close(); + }; + const accountEditField = (name) => accountEditForm?.elements.namedItem(name) || null; + page.querySelectorAll('[data-ledger-account-edit]').forEach((button) => { + button.addEventListener('click', async () => { + if (!accountEditDialog || !accountEditForm) return; + button.disabled = true; + try { + const response = await fetch(button.dataset.editUrl, { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '账户资料读取失败'); + const account = payload.account || {}; + accountEditForm.action = button.dataset.saveUrl; + accountEditForm.reset(); + [ + 'company_name', 'account_short', 'account_type', 'status', 'region', + 'account_no', 'bank_name', 'bank_city_branch', 'company_plan', + 'account_should_keep', 'single_limit', 'daily_limit', 'monthly_limit', + 'yearly_limit', 'clearing_days', 'raise_target', 'raise_status', + 'raise_actions', 'done_actions', 'next_action', 'remark', 'owner', + ].forEach((name) => { + const field = accountEditField(name); + if (field) field.value = account[name] ?? ''; + }); + accountEditField('status').value = account.status || 'normal'; + const balanceField = page.querySelector('[data-ledger-account-current]'); + if (balanceField) { + balanceField.value = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number(account.current_balance || 0)); + } + if (accountEditError) { + accountEditError.hidden = true; + accountEditError.textContent = ''; + } + if (typeof accountEditDialog.showModal === 'function') { + accountEditDialog.showModal(); + } else { + accountEditDialog.setAttribute('open', ''); + } + accountEditField('company_name')?.focus(); + } catch (error) { + notify(error.message || '账户资料读取失败', true); + } finally { + button.disabled = false; + } + }); + }); + page.querySelector('[data-ledger-account-close]')?.addEventListener('click', closeAccountEditDialog); + page.querySelector('[data-ledger-account-cancel]')?.addEventListener('click', closeAccountEditDialog); + accountEditDialog?.addEventListener('click', (event) => { + if (event.target === accountEditDialog) closeAccountEditDialog(); + }); + accountEditForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (!accountEditForm.reportValidity()) return; + if (accountEditSave) accountEditSave.disabled = true; + accountEditForm.setAttribute('aria-busy', 'true'); + if (accountEditError) { + accountEditError.hidden = true; + accountEditError.textContent = ''; + } + try { + const response = await fetch(accountEditForm.action, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: new FormData(accountEditForm), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const firstError = Object.values(payload.errors || {})[0]?.[0]; + throw new Error(firstError || payload.message || '账户保存失败'); + } + notify(payload.message || '账户信息已保存'); + closeAccountEditDialog(); + window.location.reload(); + } catch (error) { + if (accountEditError) { + accountEditError.textContent = error.message || '账户保存失败'; + accountEditError.hidden = false; + } + notify(error.message || '账户保存失败', true); + } finally { + if (accountEditSave) accountEditSave.disabled = false; + accountEditForm.removeAttribute('aria-busy'); + } + }); + + const flowEditDialog = page.querySelector('[data-ledger-flow-dialog]'); + const flowEditForm = page.querySelector('[data-ledger-flow-form]'); + const flowEditError = page.querySelector('[data-ledger-flow-error]'); + const flowEditSave = page.querySelector('[data-ledger-flow-save]'); + const flowEditReset = page.querySelector('[data-ledger-flow-reset]'); + const flowOpening = page.querySelector('[data-ledger-flow-opening]'); + const flowInflow = page.querySelector('[data-ledger-flow-inflow]'); + const flowOutflow = page.querySelector('[data-ledger-flow-outflow]'); + const flowClosing = page.querySelector('[data-ledger-flow-closing]'); + let flowResetUrl = ''; + const updateFlowClosing = () => { + if (!flowClosing) return; + const opening = Number(flowOpening?.value || 0); + const inflow = Number(flowInflow?.value || 0); + const outflow = Number(flowOutflow?.value || 0); + flowClosing.value = (opening + inflow - outflow).toFixed(2); + }; + [flowOpening, flowInflow, flowOutflow].forEach((input) => input?.addEventListener('input', updateFlowClosing)); + const closeFlowEditDialog = () => { + if (flowEditDialog?.open) flowEditDialog.close(); + }; + page.querySelectorAll('[data-ledger-flow-edit]').forEach((button) => { + button.addEventListener('click', async () => { + if (!flowEditDialog || !flowEditForm) return; + button.disabled = true; + try { + const response = await fetch(button.dataset.editUrl, { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '流水读取失败'); + const flow = payload.flow || {}; + flowEditForm.action = button.dataset.saveUrl; + flowResetUrl = button.dataset.resetUrl; + flowEditForm.querySelector('[data-ledger-flow-date-value]').value = flow.flow_date || ''; + flowEditForm.querySelector('[data-ledger-flow-account-value]').value = flow.account_short || ''; + page.querySelector('[data-ledger-flow-date]').value = flow.flow_date || ''; + page.querySelector('[data-ledger-flow-account]').value = flow.account_short || ''; + page.querySelector('[data-ledger-flow-mode]').value = flow.manual_override ? '手动覆盖' : '自动计算'; + flowOpening.value = Number(flow.opening_balance || 0).toFixed(2); + flowInflow.value = Number(flow.inflow || 0).toFixed(2); + flowOutflow.value = Number(flow.outflow || 0).toFixed(2); + page.querySelector('[data-ledger-flow-remark]').value = flow.remark || ''; + updateFlowClosing(); + if (flowEditReset) flowEditReset.hidden = !flow.manual_override; + if (flowEditError) { + flowEditError.hidden = true; + flowEditError.textContent = ''; + } + if (typeof flowEditDialog.showModal === 'function') { + flowEditDialog.showModal(); + } else { + flowEditDialog.setAttribute('open', ''); + } + flowOpening.focus(); + } catch (error) { + notify(error.message || '流水读取失败', true); + } finally { + button.disabled = false; + } + }); + }); + page.querySelector('[data-ledger-flow-close]')?.addEventListener('click', closeFlowEditDialog); + page.querySelector('[data-ledger-flow-cancel]')?.addEventListener('click', closeFlowEditDialog); + flowEditDialog?.addEventListener('click', (event) => { + if (event.target === flowEditDialog) closeFlowEditDialog(); + }); + flowEditForm?.addEventListener('submit', async (event) => { + event.preventDefault(); + updateFlowClosing(); + if (!flowEditForm.reportValidity()) return; + if (flowEditSave) flowEditSave.disabled = true; + flowEditForm.setAttribute('aria-busy', 'true'); + if (flowEditError) { + flowEditError.hidden = true; + flowEditError.textContent = ''; + } + try { + const response = await fetch(flowEditForm.action, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: new FormData(flowEditForm), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const firstError = Object.values(payload.errors || {})[0]?.[0]; + throw new Error(firstError || payload.message || '流水保存失败'); + } + notify(payload.message || '每日资金流水已保存'); + closeFlowEditDialog(); + window.location.reload(); + } catch (error) { + if (flowEditError) { + flowEditError.textContent = error.message || '流水保存失败'; + flowEditError.hidden = false; + } + notify(error.message || '流水保存失败', true); + } finally { + if (flowEditSave) flowEditSave.disabled = false; + flowEditForm.removeAttribute('aria-busy'); + } + }); + flowEditReset?.addEventListener('click', async () => { + if (!flowResetUrl || !flowEditForm) return; + flowEditReset.disabled = true; + try { + const response = await fetch(flowResetUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-CSRF-TOKEN': flowEditForm.querySelector('input[name="_token"]')?.value || '', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new URLSearchParams({ + flow_date: flowEditForm.querySelector('[data-ledger-flow-date-value]').value, + account_short: flowEditForm.querySelector('[data-ledger-flow-account-value]').value, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '恢复自动计算失败'); + notify(payload.message || '已恢复自动计算'); + closeFlowEditDialog(); + window.location.reload(); + } catch (error) { + if (flowEditError) { + flowEditError.textContent = error.message || '恢复自动计算失败'; + flowEditError.hidden = false; + } + notify(error.message || '恢复自动计算失败', true); + } finally { + flowEditReset.disabled = false; + } + }); + const accountColumnPicker = page.querySelector('[data-ledger-column-picker]'); const accountColumnPanel = accountColumnPicker?.querySelector('[data-ledger-column-panel]'); const accountColumnOptions = accountColumnPicker?.querySelector('[data-ledger-column-options]'); diff --git a/routes/admin.php b/routes/admin.php index 84e6d3be..84da6a19 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -741,6 +741,28 @@ Route::post('workspace/financial-ledger/transactions/{id}/account', [\App\Http\C ->middleware('business.page:financial-operations,ledger') ->name('business.financial-ledger.transactions.account.update'); +Route::get('workspace/financial-ledger/accounts/{id}/edit', [\App\Http\Controllers\Business\FinanceLedger::class, 'editAccount']) + ->whereNumber('id') + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.accounts.edit'); + +Route::post('workspace/financial-ledger/accounts/{id}', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccount']) + ->whereNumber('id') + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.accounts.update'); + +Route::get('workspace/financial-ledger/flows/edit', [\App\Http\Controllers\Business\FinanceLedger::class, 'editDailyFlow']) + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.flows.edit'); + +Route::post('workspace/financial-ledger/flows/update', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateDailyFlow']) + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.flows.update'); + +Route::post('workspace/financial-ledger/flows/reset', [\App\Http\Controllers\Business\FinanceLedger::class, 'resetDailyFlow']) + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.flows.reset'); + Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountStatus']) ->middleware('business.page:financial-operations,ledger') ->name('business.financial-ledger.accounts.status'); diff --git a/scripts/company-alipay-direct/README.md b/scripts/company-alipay-direct/README.md new file mode 100644 index 00000000..2c81eff1 --- /dev/null +++ b/scripts/company-alipay-direct/README.md @@ -0,0 +1,30 @@ +# 公司非店铺支付宝每日账单 + +每天 09:45 自动读取公司支付宝账户前一天的交易记录,并导入财务台账“动账明细”。 + +## 首次登录 + +```powershell +node scripts\company-alipay-direct\open-login.mjs +``` + +脚本会打开独立 Edge 登录窗口并预填账号。图形验证码、短信或扫码确认必须由本人完成;登录成功后保留该浏览器配置目录,后续定时任务复用登录会话。 + +## 手动运行 + +```powershell +pwsh -File scripts\company-alipay-direct\run-alipay-daily.ps1 + +# 指定历史日期,只导出不导入 +pwsh -File scripts\company-alipay-direct\run-alipay-daily.ps1 -Date 2026-10-29 -DryRun +``` + +## 安装每日任务 + +```powershell +pwsh -File scripts\company-alipay-direct\install-daily-task.ps1 +``` + +账号、密码、账户简称和浏览器登录态仅保存在 `runtime/private/`,不会进入 Git。私有文件需包含 `账号:`、`密码:` 和 `支付宝账户简称:` 三项。任一登录失效、验证页面出现、空数据或导入失败都会保留上一版数据并记录日志。 + +账单导入生产时统一通过服务器受限批处理入口执行,使用独立批处理容器、上游同步锁和资源上限;支付宝登录与账单导出仍只在本机运行。 diff --git a/scripts/company-alipay-direct/export-daily.mjs b/scripts/company-alipay-direct/export-daily.mjs new file mode 100644 index 00000000..8af069dd --- /dev/null +++ b/scripts/company-alipay-direct/export-daily.mjs @@ -0,0 +1,208 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CDP, evaluate, goto, launchEdge, openPage, sleep } from '../erp-daily-bills/cdp.mjs'; + +const BASE = path.dirname(fileURLToPath(import.meta.url)); +const PROFILE = path.resolve(BASE, '../../runtime/private/alipay-profile'); +const SECRET_FILE = process.env.ALIPAY_SECRET_FILE + || path.resolve(BASE, '../../runtime/private/敏感信息.md'); +const PORT = 9335; +const arg = (name, fallback = null) => { + const hit = process.argv.find((item) => item.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : fallback; +}; +const fmt = (date) => [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), +].join('-'); +const yesterday = new Date(Date.now() - 86400000); +const targetDate = arg('date', fmt(yesterday)); +if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) throw new Error('日期必须使用 YYYY-MM-DD 格式'); +const outputDir = path.resolve(arg('out', path.join(BASE, '../../runtime/private/alipay-exports', targetDate))); +fs.mkdirSync(outputDir, { recursive: true }); +const outputFile = path.join(outputDir, `alipay-company-${targetDate}.csv`); +const stateFile = path.join(outputDir, 'last-export.json'); + +const secret = fs.readFileSync(SECRET_FILE, 'utf8'); +const account = /账号:([^\r\n]+)/.exec(secret)?.[1]?.trim(); +const password = /密码:([^\r\n]+)/.exec(secret)?.[1]?.trim(); +const accountShort = /支付宝账户简称:([^\r\n]+)/.exec(secret)?.[1]?.trim(); +if (!account || !password || !accountShort) throw new Error('私有文件缺少支付宝账号、密码或账户简称'); + +const dotDate = targetDate.replaceAll('-', '.'); +const query = new URLSearchParams({ + dateRange: 'customDate', + beginDate: dotDate, + endDate: dotDate, + beginTime: '00:00', + endTime: '24:00', + dateType: 'createDate', + fundFlow: 'all', + tradeType: 'ALL', + status: 'all', +}); +const targetUrl = `https://consumeprod.alipay.com/record/advanced.htm?${query}`; + +const { child, version } = await launchEdge({ + port: PORT, + profileDir: PROFILE, + headless: !process.argv.includes('--interactive'), +}); +const cdp = await CDP.connect(version.webSocketDebuggerUrl); +const { sessionId } = await openPage(cdp, 'about:blank'); + +async function pageInfo() { + return evaluate(cdp, sessionId, `({ + url: location.href, + title: document.title, + text: document.body?.innerText || '', + hasUser: !!document.querySelector('#J-input-user'), + hasPassword: !!document.querySelector('input[type="password"]') + })`); +} + +async function maybeLogin() { + await goto(cdp, sessionId, targetUrl); + await sleep(1600); + let state = await pageInfo(); + if (!/auth\.alipay\.com\/login/.test(state.url) && !state.hasUser && !state.hasPassword) return state; + await evaluate(cdp, sessionId, `(() => { + const el = [...document.querySelectorAll('*')].find((node) => (node.innerText || '').trim() === '账密登录'); + el && el.click(); + return true; + })()`); + await sleep(900); + await evaluate(cdp, sessionId, `(() => { + const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter ? setter.call(el, value) : (el.value = value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + const user = document.querySelector('#J-input-user'); + const pass = document.querySelector('input[type="password"]'); + if (!user || !pass) return false; + setValue(user, ${JSON.stringify(account)}); + setValue(pass, ${JSON.stringify(password)}); + const button = [...document.querySelectorAll('button')].find((node) => (node.innerText || '').replace(/\\s+/g, '') === '登录'); + button && button.click(); + return true; + })()`); + await sleep(3500); + state = await pageInfo(); + if (/请在下图依次点击|验证码|短信验证|扫码验证/.test(state.text)) { + throw new Error('支付宝登录或账单下载需要人工验证,请先运行 open-login.mjs 完成验证'); + } + if (state.hasUser && state.hasPassword) throw new Error('支付宝登录失败,请先运行 open-login.mjs 检查登录状态'); + + return state; +} + +async function waitForRecords() { + for (let attempt = 0; attempt < 20; attempt++) { + const ready = await evaluate(cdp, sessionId, `({ + rows: document.querySelectorAll('#tradeRecordsIndex tr.J-item').length, + noData: /暂无数据|没有找到符合条件的记录/.test(document.body?.innerText || ''), + begin: document.querySelector('#beginDate')?.value || '', + end: document.querySelector('#endDate')?.value || '' + })`); + if (ready.begin === dotDate && ready.end === dotDate && (ready.rows > 0 || ready.noData)) return ready; + await sleep(800); + } + throw new Error('支付宝交易记录加载超时'); +} + +function parseRows(rows) { + return rows.map((row) => { + const cells = row.cells.map((cell) => (cell.innerText || '').replace(/\s+/g, ' ').trim()); + const timeMatch = /^(\d{4})[./-](\d{1,2})[./-](\d{1,2})\s+(\d{1,2}):(\d{2})/.exec(cells[0] || ''); + const ids = /订单号[::]\s*([^|]+)\|\s*交易号[::]\s*(\d+)/.exec(cells[3] || '') || []; + const amountText = cells[5] || ''; + const numeric = Number(amountText.replace(/[^\d.-]/g, '')); + return { + time: timeMatch + ? `${timeMatch[1]}-${timeMatch[2].padStart(2, '0')}-${timeMatch[3].padStart(2, '0')} ${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}` + : '', + name: cells[2] || '', + merchantOrderNo: (ids[1] || '').trim(), + recordCode: (ids[2] || '').trim(), + counterparty: cells[4] || '', + direction: amountText.trim().startsWith('-') ? '支出' : '收入', + amount: Math.abs(numeric).toFixed(2), + status: cells[7] || '', + }; + }).filter((row) => row.time && row.recordCode && Number(row.amount) > 0); +} + +try { + await maybeLogin(); + await goto(cdp, sessionId, targetUrl); + await waitForRecords(); + + const collected = []; + const seenPages = new Set(); + for (let page = 1; page <= 200; page++) { + const rows = await evaluate(cdp, sessionId, `[...document.querySelectorAll('#tradeRecordsIndex tr.J-item')].map((row) => ({ + cells: [...row.cells].map((cell) => ({ innerText: cell.innerText || '' })) + }))`); + const pageRows = parseRows(rows); + const fingerprint = pageRows.map((row) => row.recordCode).join(',') + '|' + pageRows.length; + if (seenPages.has(fingerprint)) break; + seenPages.add(fingerprint); + collected.push(...pageRows); + + const next = await evaluate(cdp, sessionId, `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight); + const link = [...document.querySelectorAll('a')].find((el) => visible(el) + && (el.innerText || '').replace(/\\s+/g, '').includes('下一页') + && !/(disabled|fn-hide)/.test(el.className || '')); + if (!link) return false; + link.click(); + return true; + })()`); + if (!next) break; + await sleep(1500); + } + + const unique = [...new Map(collected.map((row) => [row.recordCode, row])).values()]; + if (unique.length === 0) throw new Error(`支付宝 ${targetDate} 没有可导入交易`); + const header = ['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号', '商家订单号', '备注']; + const csv = [header, ...unique.map((row) => [ + row.time, + row.counterparty, + row.name, + row.direction, + row.amount, + row.status, + row.recordCode, + row.merchantOrderNo, + '', + ])].map((line) => line.map((value) => `"${String(value).replaceAll('"', '""')}"`).join(',')).join('\r\n'); + fs.writeFileSync(outputFile, `\uFEFF${csv}\r\n`, 'utf8'); + const income = unique.filter((row) => row.direction === '收入').reduce((sum, row) => sum + Number(row.amount), 0); + const expense = unique.filter((row) => row.direction === '支出').reduce((sum, row) => sum + Number(row.amount), 0); + const state = { + ok: true, + date: targetDate, + rows: unique.length, + income: Number(income.toFixed(2)), + expense: Number(expense.toFixed(2)), + file: outputFile, + cutoffTime: new Date().toISOString(), + }; + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8'); + console.log(JSON.stringify({ ...state, accountShort })); +} catch (error) { + fs.writeFileSync(stateFile, JSON.stringify({ + ok: false, + date: targetDate, + error: String(error?.message || error), + cutoffTime: new Date().toISOString(), + }, null, 2), 'utf8'); + throw error; +} finally { + cdp.close(); + child.kill(); +} diff --git a/scripts/company-alipay-direct/install-daily-task.ps1 b/scripts/company-alipay-direct/install-daily-task.ps1 new file mode 100644 index 00000000..5a6769ab --- /dev/null +++ b/scripts/company-alipay-direct/install-daily-task.ps1 @@ -0,0 +1,75 @@ +#Requires -Version 5.1 + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [ValidatePattern('^([01]\d|2[0-3]):[0-5]\d$')] + [string] $Time = '09:45', + + [string] $TaskName = 'CaiWuXiTong-CompanyAlipayDaily' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$runner = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot 'run-alipay-daily.ps1')).Path +$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$arguments = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-WindowStyle', 'Hidden', + '-File', ('"' + $runner + '"') +) -join ' ' + +if (-not $PSCmdlet.ShouldProcess($TaskName, "register daily Alipay export at $Time")) { + return +} + +$service = New-Object -ComObject 'Schedule.Service' +$rootFolder = $null +$definition = $null +try { + $service.Connect() + $rootFolder = $service.GetFolder('\') + $definition = $service.NewTask(0) + $definition.RegistrationInfo.Description = 'CaiWuXiTong 公司非店铺支付宝前一日账单同步' + $definition.Principal.UserId = $currentUser + $definition.Principal.LogonType = 3 + $definition.Principal.RunLevel = 0 + $definition.Settings.Enabled = $true + $definition.Settings.StartWhenAvailable = $true + $definition.Settings.DisallowStartIfOnBatteries = $false + $definition.Settings.StopIfGoingOnBatteries = $false + $definition.Settings.ExecutionTimeLimit = 'PT30M' + $definition.Settings.MultipleInstances = 2 + $definition.Settings.RestartCount = 2 + $definition.Settings.RestartInterval = 'PT10M' + + $trigger = $definition.Triggers.Create(2) + $trigger.StartBoundary = (Get-Date).Date.AddDays(1).ToString('yyyy-MM-dd') + 'T' + $Time + ':00' + $trigger.DaysInterval = 1 + $trigger.Enabled = $true + + $action = $definition.Actions.Create(0) + $action.Path = $windowsPowerShell + $action.Arguments = $arguments + $action.WorkingDirectory = $PSScriptRoot + + $registered = $rootFolder.RegisterTaskDefinition( + $TaskName, + $definition, + 6, + $null, + $null, + 3, + $null + ) + Write-Host "已安装每日支付宝账单任务:$TaskName,执行时间每天 $Time。" + [void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($registered) +} finally { + foreach ($item in @($definition, $rootFolder, $service)) { + if ($null -ne $item -and [System.Runtime.InteropServices.Marshal]::IsComObject($item)) { + [void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($item) + } + } +} diff --git a/scripts/company-alipay-direct/open-login.mjs b/scripts/company-alipay-direct/open-login.mjs new file mode 100644 index 00000000..589b6949 --- /dev/null +++ b/scripts/company-alipay-direct/open-login.mjs @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CDP, evaluate, goto, launchEdge, openPage, sleep } from '../erp-daily-bills/cdp.mjs'; + +const BASE = path.dirname(fileURLToPath(import.meta.url)); +const PROFILE = path.resolve(BASE, '../../runtime/private/alipay-profile'); +const SECRET_FILE = process.env.ALIPAY_SECRET_FILE + || path.resolve(BASE, '../../runtime/private/敏感信息.md'); +const PORT = 9335; + +const text = fs.readFileSync(SECRET_FILE, 'utf8'); +const account = /账号:([^\r\n]+)/.exec(text)?.[1]?.trim(); +const password = /密码:([^\r\n]+)/.exec(text)?.[1]?.trim(); +if (!account || !password) throw new Error('私有文件缺少支付宝账号或密码'); + +const { child, version } = await launchEdge({ + port: PORT, + profileDir: PROFILE, + headless: false, + detached: true, +}); +child.unref(); +const cdp = await CDP.connect(version.webSocketDebuggerUrl); +const { sessionId } = await openPage(cdp, 'about:blank'); +await goto(cdp, sessionId, 'https://auth.alipay.com/login/index.htm?goto=https%3A%2F%2Fconsumeprod.alipay.com%2Frecord%2Fadvanced.htm'); +await sleep(1800); + +let loginInputs = await evaluate(cdp, sessionId, `({ + user: !!document.querySelector('#J-input-user'), + password: !!document.querySelector('input[type="password"]') +})`); +if (!loginInputs.user || !loginInputs.password) { + await evaluate(cdp, sessionId, `(() => { + const el = [...document.querySelectorAll('*')].find((node) => (node.innerText || '').trim() === '账密登录'); + el && el.click(); + return true; + })()`); + await sleep(800); + loginInputs = await evaluate(cdp, sessionId, `({ + user: !!document.querySelector('#J-input-user'), + password: !!document.querySelector('input[type="password"]') + })`); +} +if (loginInputs.user && loginInputs.password) { + await evaluate(cdp, sessionId, `(() => { + const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter ? setter.call(el, value) : (el.value = value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + document.querySelector('#J-input-user') && setValue(document.querySelector('#J-input-user'), ${JSON.stringify(account)}); + const password = document.querySelector('input[type="password"]'); + password && setValue(password, ${JSON.stringify(password)}); + return true; + })()`); +} + +console.log('支付宝登录窗口已打开。账号已预填,请完成图形验证码或短信验证;登录成功后关闭窗口即可。'); +cdp.close(); +process.exit(0); diff --git a/scripts/company-alipay-direct/run-alipay-daily.ps1 b/scripts/company-alipay-direct/run-alipay-daily.ps1 new file mode 100644 index 00000000..2b9dc25b --- /dev/null +++ b/scripts/company-alipay-direct/run-alipay-daily.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param( + [ValidatePattern('^\d{4}-\d{2}-\d{2}$')] + [string] $Date, + + [string] $Server = '218.93.190.75', + + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string] $SshUser = 'root', + + [string] $InputFile, + + [switch] $DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$secretFile = Join-Path $root 'runtime\private\敏感信息.md' +$exportScript = Join-Path $PSScriptRoot 'export-daily.mjs' +$logDir = Join-Path $root 'runtime\private\alipay-exports\logs' +New-Item -ItemType Directory -Force -Path $logDir | Out-Null +$logFile = Join-Path $logDir ((Get-Date -Format 'yyyyMMdd') + '.log') + +function Write-Log { + param([string] $Message) + $line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message + Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 +} + +if (-not (Test-Path -LiteralPath $secretFile -PathType Leaf)) { + throw '缺少 runtime/private/敏感信息.md' +} +$secret = Get-Content -LiteralPath $secretFile -Raw +$account = [regex]::Match($secret, '账号:([^\r\n]+)').Groups[1].Value.Trim() +$accountShort = [regex]::Match($secret, '支付宝账户简称:([^\r\n]+)').Groups[1].Value.Trim() +if ($account -notmatch '^\d{6,30}$') { + throw '私有文件中的支付宝账号格式无效' +} +if ([string]::IsNullOrWhiteSpace($accountShort)) { + throw '私有文件中的支付宝账户简称不能为空' +} +if ([string]::IsNullOrWhiteSpace($Date)) { + $Date = (Get-Date).Date.AddDays(-1).ToString('yyyy-MM-dd') +} + +$outputDir = Join-Path $root ('runtime\private\alipay-exports\' + $Date) +$localFile = Join-Path $outputDir ('alipay-company-' + $Date + '.csv') +if (-not [string]::IsNullOrWhiteSpace($InputFile)) { + $localFile = (Resolve-Path -LiteralPath $InputFile -ErrorAction Stop).Path +} else { + $node = (Get-Command node.exe -ErrorAction Stop).Source + Write-Log ('开始导出支付宝账单:' + $Date) + $exportOutput = (& $node $exportScript ('--date=' + $Date) ('--out=' + $outputDir) 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $localFile -PathType Leaf)) { + Write-Log ('支付宝导出失败:' + ($exportOutput.Trim() -replace "`r?`n", ' | ')) + throw '支付宝账单导出失败;未写入财务台账' + } + $state = Get-Content -LiteralPath (Join-Path $outputDir 'last-export.json') -Raw | ConvertFrom-Json + if (-not $state.ok -or [int] $state.rows -le 0) { + Write-Log '支付宝账单为空;未写入财务台账' + throw '支付宝账单为空;未写入财务台账' + } + Write-Log ('支付宝导出完成:{0} 笔,收入 {1},支出 {2}' -f $state.rows, $state.income, $state.expense) +} + +if ($DryRun) { + Write-Log 'DryRun:执行远程校验,不写入财务台账' +} + +. (Join-Path $root 'scripts\deploy-auth.ps1') +$remoteFile = '/opt/caiwuxitong/runtime/private/alipay-imports/' + $Date + '/alipay-company.csv' +$auth = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser +try { + $target = "${SshUser}@${Server}" + $options = $auth.Options + & ssh @options $target ("install -d -m 700 '" + [IO.Path]::GetDirectoryName($remoteFile).Replace('\', '/') + "'") + if ($LASTEXITCODE -ne 0) { throw '创建支付宝账单远程目录失败' } + & scp @options $localFile ("${target}:" + $remoteFile) + if ($LASTEXITCODE -ne 0) { throw '上传支付宝账单失败' } + + $dryRunFlag = if ($DryRun) { ' --dry-run' } else { '' } + $escapedShort = $accountShort.Replace("'", "'\''") + $command = @( + 'set -eu', + ("sh /opt/caiwuxitong/run-finance-batch.sh finance-ledger:import-company-alipay " + + "'$remoteFile' --date='$Date' --account='$account' --account-short='" + + $escapedShort + "'" + $dryRunFlag) + ) -join ' && ' + $importOutput = (& ssh @options $target $command 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + Write-Log ('支付宝导入失败:' + ($importOutput.Trim() -replace "`r?`n", ' | ')) + throw '支付宝账单导入失败' + } + Write-Log ('支付宝导入完成:' + ($importOutput.Trim() -replace "`r?`n", ' | ')) +} finally { + Stop-CaiwuDeploymentAuth $auth +} diff --git a/scripts/erp-daily-bills/cdp.mjs b/scripts/erp-daily-bills/cdp.mjs index 51898361..c7d63b20 100644 --- a/scripts/erp-daily-bills/cdp.mjs +++ b/scripts/erp-daily-bills/cdp.mjs @@ -4,7 +4,13 @@ import fs from 'node:fs'; const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; -export async function launchEdge({ port = 9333, profileDir, headless = true, downloadDir } = {}) { +export async function launchEdge({ + port = 9333, + profileDir, + headless = true, + downloadDir, + detached = false, +} = {}) { const args = [ `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, @@ -15,7 +21,7 @@ export async function launchEdge({ port = 9333, profileDir, headless = true, dow ]; if (headless) args.push('--headless=new'); if (downloadDir) args.push('--disable-popup-blocking'); - const child = spawn(EDGE, args, { detached: false, stdio: 'ignore' }); + const child = spawn(EDGE, args, { detached, stdio: 'ignore' }); let version = null; for (let i = 0; i < 60; i++) { try { diff --git a/tests/Feature/Business/FinanceLedgerManualEditTest.php b/tests/Feature/Business/FinanceLedgerManualEditTest.php new file mode 100644 index 00000000..f8a5f93f --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerManualEditTest.php @@ -0,0 +1,277 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail(), + ); + + $connection = DB::connection('business_mirror'); + $accountId = $connection->table('fin_accounts')->insertGetId([ + 'company_name' => '手动编辑测试公司', + 'account_short' => '手动编辑测试账户', + 'account_type' => '公户', + 'account_no' => '6222000000008899', + 'current_balance' => 100, + 'daily_available' => 100, + ]); + $this->accountId = (int) $accountId; + $connection->table('fin_account_opening')->insert([ + 'account_short' => '手动编辑测试账户', + 'opening_date' => '2026-10-17', + 'opening_balance' => 100, + ]); + $connection->table('fin_transactions')->insert([ + [ + 'txn_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + 'detail' => '自动入账', + 'income' => 25, + 'expense' => 0, + 'source' => 'manual', + 'process_type' => '手工录入', + 'row_key' => hash('sha256', 'manual-edit-income'), + ], + [ + 'txn_date' => '2026-10-19', + 'account_short' => '手动编辑测试账户', + 'detail' => '自动出账', + 'income' => 0, + 'expense' => 30, + 'source' => 'manual', + 'process_type' => '手工录入', + 'row_key' => hash('sha256', 'manual-edit-expense'), + ], + ]); + app(FinanceLedgerCalculator::class)->recalculate(); + } + + private int $accountId; + + public function test_account_and_daily_flow_edit_controls_are_visible(): void + { + $html = $this->loginAs()->get($this->pageUrl())->assertOk()->getContent(); + + $this->assertStringContainsString('data-ledger-account-edit', $html); + $this->assertStringContainsString('data-ledger-account-dialog', $html); + $this->assertStringContainsString('data-ledger-flow-edit', $html); + $this->assertStringContainsString('data-ledger-flow-dialog', $html); + } + + public function test_account_editor_updates_fields_renames_references_and_writes_audit(): void + { + $this->loginAs()->get($this->accountEditUrl()) + ->assertOk() + ->assertJsonPath('account.id', $this->accountId) + ->assertJsonPath('account.account_short', '手动编辑测试账户'); + + $this->loginAs()->postJson($this->accountUpdateUrl(), [ + 'company_name' => '手动编辑测试公司', + 'account_short' => '手动编辑测试账户新', + 'account_type' => '公户', + 'status' => 'limited', + 'region' => '苏州', + 'account_no' => '6222000000008899', + 'bank_name' => '测试银行', + 'bank_city_branch' => '苏州分行', + 'company_plan' => '待注销', + 'account_should_keep' => '否', + 'single_limit' => '100000', + 'daily_limit' => '200000', + 'monthly_limit' => '300000', + 'yearly_limit' => '400000', + 'clearing_days' => 2.5, + 'raise_target' => '500000', + 'raise_status' => '处理中', + 'raise_actions' => '提升限额', + 'done_actions' => '提交资料', + 'next_action' => '等待审核', + 'remark' => '账户手动修改', + 'owner' => '财务负责人', + ])->assertOk()->assertJsonPath('account.account_short', '手动编辑测试账户新'); + + $connection = DB::connection('business_mirror'); + $this->assertDatabaseHas('fin_accounts', [ + 'id' => $this->accountId, + 'account_short' => '手动编辑测试账户新', + 'region' => '苏州', + 'bank_name' => '测试银行', + 'owner' => '财务负责人', + ], 'business_mirror'); + foreach (['fin_account_opening', 'fin_daily_flows', 'fin_transactions', 'fin_shops'] as $table) { + $this->assertSame( + 0, + $connection->table($table)->where('account_short', '手动编辑测试账户')->count(), + $table . ' 仍保留旧账户简称', + ); + } + $this->assertDatabaseHas('fin_account_statuses', [ + 'account_short' => '手动编辑测试账户新', + 'status' => 'limited', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_ledger_edit_events', [ + 'entity_type' => 'account', + 'entity_key' => (string) $this->accountId, + 'user_name' => 'Test User', + ], 'business_mirror'); + } + + public function test_daily_flow_override_cascades_and_can_reset_to_automatic(): void + { + $this->loginAs()->get($this->flowEditUrl('2026-10-18')) + ->assertOk() + ->assertJsonPath('flow.opening_balance', 100) + ->assertJsonPath('flow.inflow', 25) + ->assertJsonPath('flow.closing_balance', 125); + + $this->loginAs()->postJson($this->flowUpdateUrl(), [ + 'flow_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + 'opening_balance' => 100, + 'inflow' => 50, + 'outflow' => 10, + 'closing_balance' => 140, + 'remark' => '银行流水手工校正', + ])->assertOk() + ->assertJsonPath('flow.manual_override', true) + ->assertJsonPath('flow.closing_balance', 140); + + $connection = DB::connection('business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + 'inflow' => 50, + 'outflow' => 10, + 'closing_balance' => 140, + 'manual_override' => 1, + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-19', + 'account_short' => '手动编辑测试账户', + 'opening_balance' => 140, + 'outflow' => 30, + 'closing_balance' => 110, + ], 'business_mirror'); + $this->assertSame(110.0, (float) $connection->table('fin_accounts') + ->where('id', $this->accountId) + ->value('current_balance')); + + $this->loginAs()->postJson($this->flowResetUrl(), [ + 'flow_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + ])->assertOk()->assertJsonPath('flow.manual_override', false); + + $this->assertDatabaseMissing('fin_daily_flow_overrides', [ + 'flow_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-19', + 'account_short' => '手动编辑测试账户', + 'opening_balance' => 125, + 'closing_balance' => 95, + ], 'business_mirror'); + } + + public function test_manual_edit_validates_equation_duplicate_short_and_permission(): void + { + $this->withExceptionHandling()->loginAs()->postJson($this->flowUpdateUrl(), [ + 'flow_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + 'opening_balance' => 100, + 'inflow' => 10, + 'outflow' => 5, + 'closing_balance' => 120, + ])->assertUnprocessable()->assertJsonValidationErrors('closing_balance'); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + 'company_name' => '重名公司', + 'account_short' => '已存在账户简称', + ]); + $this->withExceptionHandling()->loginAs()->postJson($this->accountUpdateUrl(), [ + 'company_name' => '手动编辑测试公司', + 'account_short' => '已存在账户简称', + 'status' => 'normal', + ])->assertUnprocessable()->assertJsonValidationErrors('account_short'); + + $viewer = user_model_class()::create([ + 'name' => '台账只读用户', + 'email' => 'ledger-viewer-' . uniqid() . '@example.com', + 'password' => 'local-test-password', + 'locale' => 'zh-CN', + 'enabled' => 1, + 'landing_page' => 'dashboard', + 'created_from' => 'core::test', + ]); + $viewer->companies()->attach($this->company->id); + $viewer->roles()->attach(Role::where('name', 'user')->firstOrFail()->id); + $viewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->withExceptionHandling()->loginAs($viewer)->postJson($this->flowUpdateUrl(), [ + 'flow_date' => '2026-10-18', + 'account_short' => '手动编辑测试账户', + 'opening_balance' => 100, + 'inflow' => 10, + 'outflow' => 5, + 'closing_balance' => 105, + ])->assertForbidden(); + } + + private function pageUrl(): string + { + return route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-operations', + 'page' => 'ledger', + ]) . '?tab=flows'; + } + + private function accountEditUrl(): string + { + return route('business.financial-ledger.accounts.edit', [ + 'company_id' => $this->company->id, + 'id' => $this->accountId, + ]); + } + + private function accountUpdateUrl(): string + { + return route('business.financial-ledger.accounts.update', [ + 'company_id' => $this->company->id, + 'id' => $this->accountId, + ]); + } + + private function flowEditUrl(string $date): string + { + return route('business.financial-ledger.flows.edit', [ + 'company_id' => $this->company->id, + 'flow_date' => $date, + 'account_short' => '手动编辑测试账户', + ]); + } + + private function flowUpdateUrl(): string + { + return route('business.financial-ledger.flows.update', ['company_id' => $this->company->id]); + } + + private function flowResetUrl(): string + { + return route('business.financial-ledger.flows.reset', ['company_id' => $this->company->id]); + } +} diff --git a/tests/Feature/Console/ImportCompanyAlipayBillsTest.php b/tests/Feature/Console/ImportCompanyAlipayBillsTest.php new file mode 100644 index 00000000..5c923cc7 --- /dev/null +++ b/tests/Feature/Console/ImportCompanyAlipayBillsTest.php @@ -0,0 +1,151 @@ +table('fin_accounts')->insert([ + 'company_name' => '测试公司', + 'account_short' => '测试公司支付宝', + 'account_no' => '18800000000', + 'account_type' => '支付宝', + 'current_balance' => 1000, + ]); + $connection->table('fin_account_opening')->insert([ + 'account_short' => '测试公司支付宝', + 'opening_date' => '2026-10-01', + 'opening_balance' => 1000, + ]); + + $file = $this->csvFile([ + ['交易时间', '交易分类', '交易对方', '商品说明', '收/支', '金额', '交易状态', '交易订单号', '商家订单号', '备注'], + ['2026-10-29 09:10:00', '转账', '甲公司', '货款', '收入', '100.00', '交易成功', 'ALI-1', 'M-1', '收款'], + ['2026-10-29 10:20:00', '消费', '乙公司', '服务费', '支出', '20.00', '交易成功', 'ALI-2', 'M-2', '付款'], + ['2026-10-29 11:30:00', '其他', '丙公司', '关闭订单', '收入', '999.00', '交易关闭', 'ALI-3', 'M-3', ''], + ['2026-10-28 11:30:00', '其他', '丁公司', '前一日', '收入', '300.00', '交易成功', 'ALI-4', 'M-4', ''], + ]); + + $this->artisan('finance-ledger:import-company-alipay', [ + 'path' => $file, + '--date' => '2026-10-29', + '--account' => '18800000000', + '--account-short' => '测试公司支付宝', + ])->assertExitCode(0); + + $this->assertDatabaseCount('fin_transactions', 2, 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'txn_date' => '2026-10-29', + 'account_short' => '测试公司支付宝', + 'approval_no' => 'ALI-1', + 'source' => CompanyAlipayBillImporter::SOURCE, + 'source_value' => '18800000000', + 'income' => 100, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'approval_no' => 'ALI-2', + 'expense' => 20, + ], 'business_mirror'); + $this->assertDatabaseMissing('fin_transactions', ['approval_no' => 'ALI-3'], 'business_mirror'); + $this->assertDatabaseMissing('fin_transactions', ['approval_no' => 'ALI-4'], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => CompanyAlipayBillImporter::SOURCE, + 'source_value' => '18800000000', + 'account_short' => '测试公司支付宝', + 'txn_count' => 2, + 'income_sum' => 100, + 'expense_sum' => 20, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-29', + 'account_short' => '测试公司支付宝', + 'opening_balance' => 1000, + 'inflow' => 100, + 'outflow' => 20, + 'closing_balance' => 1080, + ], 'business_mirror'); + + $replacement = $this->csvFile([ + ['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号'], + ['2026-10-29 10:20:00', '乙公司', '服务费', '支出', '50.00', '交易成功', 'ALI-2'], + ]); + $this->artisan('finance-ledger:import-company-alipay', [ + 'path' => $replacement, + '--date' => '2026-10-29', + '--account' => '18800000000', + ])->assertExitCode(0); + + $this->assertDatabaseCount('fin_transactions', 1, 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'approval_no' => 'ALI-2', + 'expense' => 50, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-10-29', + 'account_short' => '测试公司支付宝', + 'outflow' => 50, + 'closing_balance' => 950, + ], 'business_mirror'); + } + + public function test_empty_or_unknown_account_keeps_existing_rows(): void + { + DB::connection('business_mirror')->table('fin_accounts')->insert([ + 'company_name' => '测试公司', + 'account_short' => '测试公司支付宝', + 'account_no' => '18800000000', + 'account_type' => '支付宝', + ]); + DB::connection('business_mirror')->table('fin_transactions')->insert([ + 'txn_date' => '2026-10-29', + 'account_short' => '测试公司支付宝', + 'detail' => '原有记录', + 'income' => 1, + 'expense' => 0, + 'source' => CompanyAlipayBillImporter::SOURCE, + 'source_value' => '18800000000', + 'process_type' => CompanyAlipayBillImporter::PROCESS_TYPE, + 'row_key' => hash('sha256', 'existing-alipay-row'), + ]); + $empty = $this->csvFile([ + ['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号'], + ['2026-10-28 10:00:00', '甲公司', '非目标日期', '收入', '10.00', '交易成功', 'OLD-1'], + ]); + + $this->withExceptionHandling()->artisan('finance-ledger:import-company-alipay', [ + 'path' => $empty, + '--date' => '2026-10-29', + '--account' => '18800000000', + ])->assertExitCode(1); + $this->assertDatabaseHas('fin_transactions', ['detail' => '原有记录'], 'business_mirror'); + + $this->withExceptionHandling()->artisan('finance-ledger:import-company-alipay', [ + 'path' => $empty, + '--date' => '2026-10-29', + '--account' => '99999999999', + ])->assertExitCode(1); + $this->assertDatabaseHas('fin_transactions', ['detail' => '原有记录'], 'business_mirror'); + } + + /** @param list> $rows */ + private function csvFile(array $rows): string + { + $path = tempnam(sys_get_temp_dir(), 'company-alipay-') . '.csv'; + $stream = fopen($path, 'wb'); + if ($stream === false) { + throw new \RuntimeException('无法创建测试 CSV。'); + } + foreach ($rows as $row) { + fputcsv($stream, $row); + } + fclose($stream); + + return $path; + } +} -- 2.47.2