From 67c85678e600c4dca4066c3d7debcf60f2ee3b75 Mon Sep 17 00:00:00 2001 From: 92451 <924518135@qq.com> Date: Mon, 2 Nov 2026 20:58:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=BD=91=E5=95=86?= =?UTF-8?q?=E9=93=B6=E8=A1=8C=E6=AF=8F=E6=97=A5=E5=8A=A8=E8=B4=A6=E6=98=8E?= =?UTF-8?q?=E7=BB=86=20(#831)=20Co-authored-by:=2092451=20<924518135@qq.co?= =?UTF-8?q?m>=20Co-committed-by:=2092451=20<924518135@qq.com>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportWangshangBankBills.php | 57 ++ deploy/finance-batch-imports.json | 1 + .../Services/WangshangBankBillImporter.php | 683 ++++++++++++++++++ scripts/erp-daily-bills/cdp.mjs | 5 +- scripts/wangshang-bank/README.md | 47 ++ scripts/wangshang-bank/config.mjs | 73 ++ scripts/wangshang-bank/cookie-protection.ps1 | 34 + scripts/wangshang-bank/cookie-store.mjs | 91 +++ scripts/wangshang-bank/export-daily.mjs | 493 +++++++++++++ scripts/wangshang-bank/import-accounts.ps1 | 144 ++++ scripts/wangshang-bank/install-daily-task.ps1 | 73 ++ scripts/wangshang-bank/open-login.mjs | 29 + scripts/wangshang-bank/open-login.ps1 | 28 + .../wangshang-bank/run-wangshang-daily.cmd | 10 + .../wangshang-bank/run-wangshang-daily.ps1 | 152 ++++ scripts/wangshang-bank/session.mjs | 383 ++++++++++ .../Console/ImportWangshangBankBillsTest.php | 192 +++++ 17 files changed, 2494 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/ImportWangshangBankBills.php create mode 100644 modules/EcommerceFinance/Services/WangshangBankBillImporter.php create mode 100644 scripts/wangshang-bank/README.md create mode 100644 scripts/wangshang-bank/config.mjs create mode 100644 scripts/wangshang-bank/cookie-protection.ps1 create mode 100644 scripts/wangshang-bank/cookie-store.mjs create mode 100644 scripts/wangshang-bank/export-daily.mjs create mode 100644 scripts/wangshang-bank/import-accounts.ps1 create mode 100644 scripts/wangshang-bank/install-daily-task.ps1 create mode 100644 scripts/wangshang-bank/open-login.mjs create mode 100644 scripts/wangshang-bank/open-login.ps1 create mode 100644 scripts/wangshang-bank/run-wangshang-daily.cmd create mode 100644 scripts/wangshang-bank/run-wangshang-daily.ps1 create mode 100644 scripts/wangshang-bank/session.mjs create mode 100644 tests/Feature/Console/ImportWangshangBankBillsTest.php diff --git a/app/Console/Commands/ImportWangshangBankBills.php b/app/Console/Commands/ImportWangshangBankBills.php new file mode 100644 index 00000000..9dfa1f97 --- /dev/null +++ b/app/Console/Commands/ImportWangshangBankBills.php @@ -0,0 +1,57 @@ +option('date') ?: now()->subDay()->toDateString())); + + try { + $result = $importer->import( + (string) $this->argument('path'), + $date, + (bool) $this->option('dry-run'), + ); + } catch (Throwable $exception) { + $this->error('网商银行账单未导入,保留原有动账明细。' . $exception->getMessage()); + + return self::FAILURE; + } + + $this->table( + ['公司', '账户尾号', '页面笔数', '有效笔数', '收入合计', '支出合计'], + array_map(static fn (array $row): array => [ + $row['company'], + $row['account_last4'], + $row['expected_rows'] ?? '-', + $row['rows'], + number_format((float) $row['income'], 2), + number_format((float) $row['expense'], 2), + ], $result['accounts']), + ); + + $this->info(sprintf( + '%s网商银行账单:%d 个账户,%d 笔,收入 %.2f,支出 %.2f。', + $result['dry_run'] ? '校验完成,' : '已接入财务台账动账明细,', + count($result['accounts']), + $result['rows'], + $result['income'], + $result['expense'], + )); + + return self::SUCCESS; + } +} diff --git a/deploy/finance-batch-imports.json b/deploy/finance-batch-imports.json index b1ac2a98..673bdba1 100644 --- a/deploy/finance-batch-imports.json +++ b/deploy/finance-batch-imports.json @@ -10,6 +10,7 @@ "dewu-bills:import-authoritative", "business-mirror:import-douyin-authoritative", "finance-ledger:import-company-alipay", + "finance-ledger:import-wangshang-bank", "finance-ledger:import-base", "finance-ledger:import-snapshot", "internal-flow:import", diff --git a/modules/EcommerceFinance/Services/WangshangBankBillImporter.php b/modules/EcommerceFinance/Services/WangshangBankBillImporter.php new file mode 100644 index 00000000..50a5d9ab --- /dev/null +++ b/modules/EcommerceFinance/Services/WangshangBankBillImporter.php @@ -0,0 +1,683 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{ + * dry_run:bool,date:string,rows:int,income:float,expense:float, + * accounts:list>,recalculated:?array + * } + */ + public function import(string $path, string $balanceDate, bool $dryRun = false): array + { + $date = $this->date($balanceDate); + if (! is_file($path) || ! is_readable($path)) { + throw new InvalidArgumentException('网商银行批次文件不存在或不可读取。'); + } + + $temporaryDirectory = null; + try { + [$manifestPath, $temporaryDirectory] = $this->bundle($path); + $manifest = $this->manifest($manifestPath, $date); + $entries = $this->entries($manifest, dirname($manifestPath), $date); + $result = [ + 'dry_run' => $dryRun, + 'date' => $date, + 'rows' => array_sum(array_column($entries, 'rows')), + 'income' => round(array_sum(array_column($entries, 'income')), 2), + 'expense' => round(array_sum(array_column($entries, 'expense')), 2), + 'accounts' => array_map(static fn (array $entry): array => [ + 'company' => $entry['company'], + 'account_last4' => substr($entry['account'], -4), + 'expected_rows' => $entry['expected_rows'], + 'rows' => $entry['rows'], + 'income' => $entry['income'], + 'expense' => $entry['expense'], + ], $entries), + 'recalculated' => null, + ]; + if ($dryRun) { + return $result; + } + + $result['recalculated'] = $this->persist($entries, $date); + + return $result; + } finally { + if ($temporaryDirectory !== null) { + $this->removeDirectory($temporaryDirectory); + } + } + } + + /** + * @return array{0:string,1:?string} + */ + private function bundle(string $path): array + { + $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION)); + if ($extension === 'json') { + return [$path, null]; + } + if ($extension !== 'zip' || ! class_exists(ZipArchive::class)) { + throw new InvalidArgumentException('网商银行批次必须上传 ZIP 或 manifest.json。'); + } + + $directory = sys_get_temp_dir() . '/caiwu-wangshang-' . bin2hex(random_bytes(8)); + if (! mkdir($directory, 0700, true) && ! is_dir($directory)) { + throw new RuntimeException('无法创建网商银行批次临时目录。'); + } + + $archive = new ZipArchive(); + $opened = $archive->open($path); + if ($opened !== true) { + $this->removeDirectory($directory); + throw new InvalidArgumentException('网商银行批次 ZIP 无法打开。'); + } + + try { + for ($index = 0; $index < $archive->numFiles; $index++) { + $name = str_replace('\\', '/', (string) $archive->getNameIndex($index)); + if ($name === '' || str_starts_with($name, '/') || preg_match('/(^|\/)\.\.(\/|$)/', $name) === 1) { + throw new InvalidArgumentException('网商银行批次 ZIP 包含不安全路径。'); + } + if (str_ends_with($name, '/')) { + $target = $directory . '/' . $name; + if (! is_dir($target) && ! mkdir($target, 0700, true) && ! is_dir($target)) { + throw new RuntimeException('无法创建网商银行批次临时目录。'); + } + continue; + } + $source = $archive->getStream($name); + if ($source === false) { + throw new InvalidArgumentException('网商银行批次 ZIP 内容无法读取。'); + } + $target = $directory . '/' . $name; + $parent = dirname($target); + if (! is_dir($parent) && ! mkdir($parent, 0700, true) && ! is_dir($parent)) { + fclose($source); + throw new RuntimeException('无法创建网商银行批次临时目录。'); + } + $destination = fopen($target, 'wb'); + if ($destination === false) { + fclose($source); + throw new RuntimeException('无法写入网商银行批次临时文件。'); + } + stream_copy_to_stream($source, $destination); + fclose($source); + fclose($destination); + } + } finally { + $archive->close(); + } + + $manifestPath = $directory . '/manifest.json'; + if (! is_file($manifestPath)) { + $this->removeDirectory($directory); + throw new InvalidArgumentException('网商银行批次缺少 manifest.json。'); + } + + return [$manifestPath, $directory]; + } + + /** @return array */ + private function manifest(string $path, string $date): array + { + try { + $manifest = json_decode( + (string) file_get_contents($path), + true, + 512, + JSON_THROW_ON_ERROR, + ); + } catch (Throwable $exception) { + throw new InvalidArgumentException('网商银行 manifest.json 无法解析:' . $exception->getMessage()); + } + if (! is_array($manifest) || ! is_array($manifest['accounts'] ?? null) || $manifest['accounts'] === []) { + throw new InvalidArgumentException('网商银行 manifest 中没有账户明细。'); + } + if (trim((string) ($manifest['date'] ?? '')) !== $date) { + throw new InvalidArgumentException('网商银行 manifest 日期与导入日期不一致。'); + } + + return $manifest; + } + + /** + * @param array $manifest + * @return list> + */ + private function entries(array $manifest, string $directory, string $date): array + { + $entries = []; + $seenAccounts = []; + + foreach ($manifest['accounts'] as $item) { + if (! is_array($item)) { + throw new InvalidArgumentException('网商银行 manifest 账户格式无效。'); + } + $account = $this->accountNo((string) ($item['account'] ?? '')); + if (isset($seenAccounts[$account])) { + throw new InvalidArgumentException('网商银行 manifest 包含重复账号。'); + } + $seenAccounts[$account] = true; + + $company = $this->text($item['company'] ?? ''); + $accountShort = $this->text($item['account_short'] ?? ''); + if ($company === '' || $accountShort === '') { + throw new InvalidArgumentException('网商银行 manifest 缺少公司或账户简称。'); + } + $expectedRows = array_key_exists('expected_rows', $item) + ? filter_var($item['expected_rows'], FILTER_VALIDATE_INT) + : null; + if ($expectedRows === false || ($expectedRows !== null && $expectedRows < 0)) { + throw new InvalidArgumentException('网商银行 manifest 页面笔数无效。'); + } + $expectedIncome = $this->expectedMoney($item['expected_income'] ?? null); + $expectedExpense = $this->expectedMoney($item['expected_expense'] ?? null); + + $relative = trim((string) ($item['file'] ?? '')); + $rows = []; + if ($relative !== '') { + if (str_starts_with($relative, '/') || preg_match('/(^|\/)\.\.(\/|$)/', $relative) === 1) { + throw new InvalidArgumentException('网商银行 manifest 文件路径无效。'); + } + $file = $directory . '/' . str_replace('\\', '/', $relative); + if (! is_file($file) || ! is_readable($file)) { + throw new InvalidArgumentException('网商银行 manifest 引用的明细文件不存在。'); + } + $rows = $this->rows($file, $account, $accountShort, $company, $date); + } + + if ($expectedRows !== null && count($rows) !== $expectedRows) { + throw new InvalidArgumentException(sprintf( + '%s网商账户页面显示 %d 笔,但下载文件有效行为 %d 笔。', + mb_substr($company, 0, 30), + $expectedRows, + count($rows), + )); + } + if ($rows === [] && ($expectedRows === null || $expectedRows > 0)) { + throw new InvalidArgumentException(mb_substr($company, 0, 30) . '网商账户没有目标日期的有效交易。'); + } + $income = round(array_sum(array_column($rows, 'income')), 2); + $expense = round(array_sum(array_column($rows, 'expense')), 2); + if (($expectedIncome !== null && abs($income - $expectedIncome) > 0.01) + || ($expectedExpense !== null && abs($expense - $expectedExpense) > 0.01)) { + throw new InvalidArgumentException(sprintf( + '%s网商账户页面金额与下载文件不一致:页面收入 %.2f、支出 %.2f,文件收入 %.2f、支出 %.2f。', + mb_substr($company, 0, 30), + $expectedIncome ?? $income, + $expectedExpense ?? $expense, + $income, + $expense, + )); + } + + $entries[] = [ + 'company' => $company, + 'account' => $account, + 'account_short' => $this->resolvedAccountShort($account, $accountShort), + 'expected_rows' => $expectedRows, + 'rows' => count($rows), + 'income' => $income, + 'expense' => $expense, + 'transactions' => $rows, + ]; + } + + return $entries; + } + + private function resolvedAccountShort(string $accountNo, string $fallback): string + { + $matches = $this->connection->table('fin_accounts') + ->get(['account_short', 'account_no']) + ->filter(static fn (object $account): bool => + preg_replace('/\D+/', '', (string) $account->account_no) === $accountNo + ) + ->values(); + if ($matches->count() > 1) { + throw new InvalidArgumentException('网商账号在账户总表中对应多个账户,请先在账户总表处理重复项。'); + } + if ($matches->count() === 1) { + $matched = $this->text($matches->first()->account_short); + if ($matched !== '') { + return $matched; + } + } + + // 账户总表尚未补齐时仍可先展示在“动账明细”;余额计算会等账户建好后再纳入。 + return $fallback; + } + + /** + * @return list> + */ + private function rows( + string $path, + string $accountNo, + string $accountShort, + string $company, + 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; + } + + $income = abs($this->money($value('income'))); + $expense = abs($this->money($value('expense'))); + if ($income === 0.0 && $expense === 0.0 && isset($fields['amount'])) { + $rawAmount = $value('amount'); + $amount = abs($this->money($rawAmount)); + $direction = mb_strtolower($this->text($value('direction'))); + if (str_starts_with(trim($rawAmount), '-') + || str_contains($direction, '支') + || str_contains($direction, '出') + || str_contains($direction, '贷')) { + $expense = $amount; + } else { + $income = $amount; + } + } + if ($income === 0.0 && $expense === 0.0) { + continue; + } + + $recordCode = $this->text($value('record_code')); + if ($recordCode === '') { + $recordCode = hash('sha256', implode("\0", [ + $time, + $value('description'), + $value('counterparty'), + number_format($income, 2, '.', ''), + number_format($expense, 2, '.', ''), + ])); + } + $uniqueKey = $recordCode . "\0" . $time . "\0" + . number_format($income, 2, '.', '') . "\0" + . number_format($expense, 2, '.', ''); + if (isset($seen[$uniqueKey])) { + continue; + } + $seen[$uniqueKey] = true; + + $detailParts = array_values(array_filter([ + $this->text($value('description')), + $this->text($value('counterparty')), + $this->text($value('remark')), + ], static fn (string $text): bool => $text !== '')); + $detail = implode(' · ', $detailParts); + 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, + number_format($income, 2, '.', ''), + number_format($expense, 2, '.', ''), + $company, + ])), + 'synced_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + 'created_by' => '系统自动同步', + 'updated_by' => '系统自动同步', + ]; + } + + return $rows; + } + + /** + * @param list> $entries + * @return array + */ + private function persist(array $entries, string $date): array + { + return $this->connection->transaction(function () use ($entries, $date): array { + foreach ($entries as $entry) { + $this->connection->table('fin_transactions') + ->where('source', self::SOURCE) + ->where('source_value', $entry['account']) + ->whereDate('txn_date', $date) + ->delete(); + foreach (array_chunk($entry['transactions'], 500) as $chunk) { + $this->connection->table('fin_transactions')->insert($chunk); + } + + $stats = $this->connection->table('fin_transactions') + ->where('source', self::SOURCE) + ->where('source_value', $entry['account']) + ->selectRaw('COUNT(*) AS txn_count, COALESCE(SUM(income), 0) AS income_sum, COALESCE(SUM(expense), 0) AS expense_sum') + ->first(); + $this->connection->table('fin_account_map')->updateOrInsert( + ['source' => self::SOURCE, 'source_value' => $entry['account']], + [ + 'account_short' => $entry['account_short'], + 'txn_count' => (int) ($stats->txn_count ?? 0), + 'income_sum' => round((float) ($stats->income_sum ?? 0), 2), + 'expense_sum' => round((float) ($stats->expense_sum ?? 0), 2), + 'updated_by' => '系统自动同步', + 'updated_at' => now(), + 'created_at' => now(), + ], + ); + } + + return app(FinanceLedgerCalculator::class)->recalculate(); + }); + } + + /** + * @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' => ['账务流水号', '流水号', '交易流水号', '交易单号'], + 'income' => ['借方金额(收)', '借方金额(收)', '借方金额', '收入', '收入金额', '收款金额'], + 'expense' => ['贷方金额(支)', '贷方金额(支)', '贷方金额', '支出', '支出金额', '付款金额'], + 'amount' => ['交易金额', '金额(元)', '金额(元)', '金额'], + 'direction' => ['收/支', '收支', '借贷标志', '收支方向', '交易方向'], + 'description' => ['交易名称', '摘要', '用途', '商品说明'], + 'counterparty' => ['对方户名', '交易对手', '对方名称', '交易对方'], + 'status' => ['交易状态', '状态'], + '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']) || ! $this->hasAny($headers, $aliases['income']) + && ! $this->hasAny($headers, $aliases['expense']) && ! isset($fields['amount'])) { + throw new InvalidArgumentException('网商银行账单缺少交易时间或金额列。'); + } + + return $fields; + } + + private function money(string $value): float + { + $value = trim($value); + if ($value === '' || $value === '-' || $value === '--') { + return 0.0; + } + $negative = str_starts_with($value, '-'); + $number = preg_replace('/[^\d.]/', '', $value); + if ($number === '' || $number === null) { + return 0.0; + } + + return round(abs((float) $number), 2) * ($negative ? -1 : 1); + } + + private function expectedMoney(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + if (! is_numeric($value)) { + throw new InvalidArgumentException('网商银行 manifest 页面金额无效。'); + } + + return round(abs((float) $value), 2); + } + + 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 accountNo(string $value): string + { + $digits = preg_replace('/\D+/', '', $value) ?? ''; + if (strlen($digits) < 6 || strlen($digits) > 30) { + throw new InvalidArgumentException('网商银行账号格式无效。'); + } + + return $digits; + } + + private function text(mixed $value): string + { + return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value); + } + + private function removeDirectory(string $directory): void + { + $normalized = rtrim(str_replace('\\', '/', $directory), '/'); + $temporary = rtrim(str_replace('\\', '/', sys_get_temp_dir()), '/'); + if (! str_starts_with($normalized . '/', $temporary . '/caiwu-wangshang-')) { + return; + } + if (! is_dir($normalized)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($normalized, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($normalized); + } +} diff --git a/scripts/erp-daily-bills/cdp.mjs b/scripts/erp-daily-bills/cdp.mjs index c7d63b20..2dac00be 100644 --- a/scripts/erp-daily-bills/cdp.mjs +++ b/scripts/erp-daily-bills/cdp.mjs @@ -10,6 +10,8 @@ export async function launchEdge({ headless = true, downloadDir, detached = false, + windowSize = '1600,1000', + forceDeviceScaleFactor = null, } = {}) { const args = [ `--remote-debugging-port=${port}`, @@ -17,8 +19,9 @@ export async function launchEdge({ '--no-first-run', '--no-default-browser-check', '--disable-features=Translate,OptimizationHints', - '--window-size=1600,1000', + `--window-size=${windowSize}`, ]; + if (forceDeviceScaleFactor) args.push(`--force-device-scale-factor=${forceDeviceScaleFactor}`); if (headless) args.push('--headless=new'); if (downloadDir) args.push('--disable-popup-blocking'); const child = spawn(EDGE, args, { detached, stdio: 'ignore' }); diff --git a/scripts/wangshang-bank/README.md b/scripts/wangshang-bank/README.md new file mode 100644 index 00000000..e62730b5 --- /dev/null +++ b/scripts/wangshang-bank/README.md @@ -0,0 +1,47 @@ +# 网商银行每日交易明细 + +每天从支付宝企业工作台进入网商银行企业网银,查询并下载前一天的交易明细,批量写入财务台账“动账明细”。 + +## 首次准备 + +```powershell +pwsh -File scripts\wangshang-bank\import-accounts.ps1 +pwsh -File scripts\wangshang-bank\open-login.ps1 +``` + +账号表默认读取 `D:\zyc\网商银行账号密码.xlsx`。脚本生成: + +- `runtime/private/wangshang-bank-accounts.json`:公司、支付宝账号密码、网商账号和账户简称。 +- `runtime/private/wangshang-bank-profiles/`:每个网商账号独立的 Edge 登录态。 +- 登录 Cookie 额外使用 Windows 当前用户 DPAPI 加密保存,后续任务可直接恢复会话。 + +若支付宝要求短信、扫码或图形验证,在打开的 Edge 窗口完成验证即可;脚本最多等待 5 分钟,成功后自动关闭该账户窗口并继续下一个账户。 + +## 手动运行 + +```powershell +# 导出并导入昨天 +pwsh -File scripts\wangshang-bank\run-wangshang-daily.ps1 + +# 指定日期,只导出 +pwsh -File scripts\wangshang-bank\run-wangshang-daily.ps1 -Date 2026-11-01 -SkipImport + +# 指定公司,校验端到端链路但不写台账 +pwsh -File scripts\wangshang-bank\run-wangshang-daily.ps1 -Company 株洲市珉佳 -Date 2026-11-01 -DryRun +``` + +## 安装每日任务 + +```powershell +pwsh -File scripts\wangshang-bank\install-daily-task.ps1 +``` + +任务名为 `CaiWuXiTong-WangshangBankDaily`,默认每天 10:00 执行;电脑休眠时会在恢复后补跑。 + +## 数据规则 + +- 页面笔数和下载文件有效行必须一致,否则整批不写入。 +- 每批先完整解析,再按“网商银行来源 + 账号 + 日期”替换,最后统一重算一次台账。 +- 部分账户登录失效时,成功账户仍会导入,失败账户保持原数据,任务以失败状态结束并写日志。 +- 账户总表暂未登记网商账号时,明细仍会按公司简称显示在“动账明细”;补齐账户和期初余额后才会进入余额计算。 +- 密码、登录态、下载账单和日志只保存在 `runtime/private/`,不会进入 Git。 diff --git a/scripts/wangshang-bank/config.mjs b/scripts/wangshang-bank/config.mjs new file mode 100644 index 00000000..61f21454 --- /dev/null +++ b/scripts/wangshang-bank/config.mjs @@ -0,0 +1,73 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const BASE = path.dirname(fileURLToPath(import.meta.url)); +export const ROOT = path.resolve(BASE, '../..'); +export const CONFIG_FILE = path.resolve( + process.env.WANGSHANG_BANK_CONFIG_FILE + || path.join(ROOT, 'runtime/private/wangshang-bank-accounts.json'), +); +export const PROFILE_ROOT = path.resolve( + process.env.WANGSHANG_BANK_PROFILE_ROOT + || path.join(ROOT, 'runtime/private/wangshang-bank-profiles'), +); + +export function accountDigits(value) { + return String(value ?? '').replace(/\D/g, ''); +} + +export function maskAccount(value) { + const digits = accountDigits(value); + return digits.length >= 4 ? `****${digits.slice(-4)}` : '****'; +} + +export function profileDirectory(account) { + const key = crypto + .createHash('sha256') + .update(accountDigits(account.bank_account)) + .digest('hex') + .slice(0, 16); + + return path.join(PROFILE_ROOT, key); +} + +export function loadAccounts() { + if (!fs.existsSync(CONFIG_FILE)) { + throw new Error('缺少 runtime/private/wangshang-bank-accounts.json,请先运行 import-accounts.ps1'); + } + const payload = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + const source = Array.isArray(payload) ? payload : payload.accounts; + if (!Array.isArray(source) || source.length === 0) { + throw new Error('网商银行私有配置没有账户'); + } + + const accounts = source.map((item, index) => { + const company = String(item.company ?? '').trim(); + const alipayAccount = String(item.alipay_account ?? '').trim(); + const alipayPassword = String(item.alipay_password ?? item.password ?? ''); + const bankAccount = accountDigits(item.bank_account); + const accountShort = String(item.account_short ?? '').trim(); + if (!company || !alipayAccount || !alipayPassword || bankAccount.length < 6 || !accountShort) { + throw new Error(`网商银行私有配置第 ${index + 1} 行缺少公司、支付宝账号、密码、网商账号或账户简称`); + } + + return { + company, + alipay_account: alipayAccount, + alipay_password: alipayPassword, + bank_account: bankAccount, + account_short: accountShort, + }; + }); + if (new Set(accounts.map((item) => item.bank_account)).size !== accounts.length) { + throw new Error('网商银行私有配置存在重复账号'); + } + + return accounts; +} + +export function accountLabel(account) { + return `${account.company}(${maskAccount(account.bank_account)})`; +} diff --git a/scripts/wangshang-bank/cookie-protection.ps1 b/scripts/wangshang-bank/cookie-protection.ps1 new file mode 100644 index 00000000..572b6e00 --- /dev/null +++ b/scripts/wangshang-bank/cookie-protection.ps1 @@ -0,0 +1,34 @@ +#Requires -Version 5.1 + +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Protect', 'Unprotect')] + [string] $Action +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName System.Security +$payload = [Console]::In.ReadToEnd().Trim() +if ([string]::IsNullOrWhiteSpace($payload)) { + throw 'Cookie protection input is empty' +} + +$bytes = [Convert]::FromBase64String($payload) +$entropy = [Text.Encoding]::UTF8.GetBytes('CaiWuXiTong.WangshangBank.Cookies.v1') +if ($Action -eq 'Protect') { + $output = [Security.Cryptography.ProtectedData]::Protect( + $bytes, + $entropy, + [Security.Cryptography.DataProtectionScope]::CurrentUser + ) +} else { + $output = [Security.Cryptography.ProtectedData]::Unprotect( + $bytes, + $entropy, + [Security.Cryptography.DataProtectionScope]::CurrentUser + ) +} + +[Console]::Out.Write([Convert]::ToBase64String($output)) diff --git a/scripts/wangshang-bank/cookie-store.mjs b/scripts/wangshang-bank/cookie-store.mjs new file mode 100644 index 00000000..d998a1e5 --- /dev/null +++ b/scripts/wangshang-bank/cookie-store.mjs @@ -0,0 +1,91 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { profileDirectory } from './config.mjs'; + +const BASE = path.dirname(fileURLToPath(import.meta.url)); +const HELPER = path.join(BASE, 'cookie-protection.ps1'); +const POWERSHELL = path.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32/WindowsPowerShell/v1.0/powershell.exe', +); +const DOMAINS = [ + '.alipay.com', + 'alipay.com', + 'auth.alipay.com', + 'b.alipay.com', + 'authweb.alipay.com', + '.mybank.cn', + 'mybank.cn', + 'b.mybank.cn', + 'fund.mybank.cn', + 'login.mybank.cn', +]; + +function cookieFile(account) { + return path.join(profileDirectory(account), 'auth-cookies.dpapi'); +} + +function protect(value) { + return execFileSync( + POWERSHELL, + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', HELPER, '-Action', 'Protect'], + { input: Buffer.from(value, 'utf8'), maxBuffer: 32 * 1024 * 1024, windowsHide: true }, + ).toString('utf8').trim(); +} + +function unprotect(value) { + return execFileSync( + POWERSHELL, + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', HELPER, '-Action', 'Unprotect'], + { input: Buffer.from(value, 'utf8'), maxBuffer: 32 * 1024 * 1024, windowsHide: true }, + ).toString('utf8').trim(); +} + +function allowed(cookie) { + const domain = String(cookie.domain || '').toLowerCase(); + return DOMAINS.some((item) => domain === item || domain.endsWith(item)); +} + +export async function saveCookies(cdp, account) { + const payload = await cdp.send('Storage.getCookies'); + const cookies = (payload.cookies || []).filter(allowed); + if (cookies.length === 0) return false; + + const file = cookieFile(account); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const encrypted = protect(Buffer.from(JSON.stringify(cookies), 'utf8').toString('base64')); + const temporary = file + '.tmp'; + fs.writeFileSync(temporary, encrypted + '\r\n', { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(temporary, file); + + return true; +} + +export async function restoreCookies(cdp, account) { + const file = cookieFile(account); + if (!fs.existsSync(file)) return false; + + try { + const encrypted = fs.readFileSync(file, 'utf8').trim(); + const json = Buffer.from(unprotect(encrypted), 'base64').toString('utf8'); + const cookies = JSON.parse(json); + if (!Array.isArray(cookies) || cookies.length === 0) return false; + + const allowedKeys = [ + 'name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', + 'sameSite', 'priority', 'sameParty', 'sourceScheme', 'sourcePort', + ]; + await cdp.send('Storage.setCookies', { + cookies: cookies.map((cookie) => Object.fromEntries( + allowedKeys + .filter((key) => cookie[key] !== undefined) + .map((key) => [key, cookie[key]]), + )), + }); + return true; + } catch { + return false; + } +} diff --git a/scripts/wangshang-bank/export-daily.mjs b/scripts/wangshang-bank/export-daily.mjs new file mode 100644 index 00000000..3d177848 --- /dev/null +++ b/scripts/wangshang-bank/export-daily.mjs @@ -0,0 +1,493 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + clickVisibleText, + clickVisibleSelector, + inspectTransactionPage, + launchAccountSession, + openTransactionDetail, + pageInfo, +} from './session.mjs'; +import { accountLabel, loadAccounts, maskAccount } from './config.mjs'; +import { shot, sleep } from '../erp-daily-bills/cdp.mjs'; + +const arg = (name, fallback = null) => { + const hit = process.argv.find((item) => item.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : fallback; +}; +const hasFlag = (name) => process.argv.includes(`--${name}`); +const formatDate = (date) => [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), +].join('-'); + +const targetDate = arg('date', formatDate(new Date(Date.now() - 86400000))); +if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) throw new Error('日期必须使用 YYYY-MM-DD 格式'); + +const outputRoot = path.resolve(arg('out', path.join(process.cwd(), 'runtime/private/wangshang-bank-exports', targetDate))); +const runId = new Date().toISOString().replace(/\D/g, '').slice(0, 14); +const requestedRunDirectory = arg('run-dir', ''); +const runDirectory = requestedRunDirectory + ? path.resolve(requestedRunDirectory) + : path.join(outputRoot, runId); +const accountRoot = path.join(runDirectory, 'accounts'); +fs.mkdirSync(accountRoot, { recursive: true }); + +const manifestFile = path.join(runDirectory, 'manifest.json'); +const stateFile = path.join(runDirectory, 'last-export.json'); +const companyFilter = String(arg('company', '')).trim(); +const limit = Math.max(1, Number(arg('limit', '999')) || 999); +const interactive = hasFlag('interactive'); +const probe = hasFlag('probe'); +const accounts = loadAccounts() + .filter((account) => !companyFilter || account.company.includes(companyFilter)) + .slice(0, limit); + +if (accounts.length === 0) throw new Error('没有匹配的网商银行账户'); + +function safeName(value, fallback) { + const name = String(value || '') + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-') + .replace(/\s+/g, '') + .slice(0, 60); + return name || fallback; +} + +function sanitize(value) { + return String(value ?? '') + .replace(/\b\d{10,30}\b/g, (digits) => `****${digits.slice(-4)}`) + .slice(0, 600); +} + +function filesIn(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory) + .map((name) => path.join(directory, name)) + .filter((file) => fs.statSync(file).isFile()); +} + +async function waitForDownload(directory, beforeFiles, timeoutMs = 90000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const downloads = filesIn(directory) + .filter((file) => !beforeFiles.has(file) && !file.endsWith('.crdownload')) + .filter((file) => { + try { + return Date.now() - fs.statSync(file).mtimeMs > 900; + } catch { + return false; + } + }); + if (downloads.length > 0) return downloads.sort( + (left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs, + )[0]; + await sleep(700); + } + return null; +} + +async function setTargetDateAndQuery(session) { + let result; + let value; + for (let attempt = 0; attempt < 20; attempt++) { + result = await session.cdp.send('Runtime.evaluate', { + expression: `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight); + const datePattern = /^\\d{4}-\\d{2}-\\d{2}$/; + const inputs = [...document.querySelectorAll('input')] + .filter((el) => visible(el) && !el.disabled && el.type !== 'hidden'); + let dates = inputs.filter((el) => datePattern.test(el.value || '')); + if (dates.length < 2) { + dates = inputs.filter((el) => + /日期|开始|结束|交易时间/.test([el.placeholder, el.getAttribute('aria-label')].join(' ')) + ); + } + if (dates.length < 2) return { + ok: false, + error: '未找到开始和结束日期输入框', + inputs: inputs.map((el) => ({ + type: el.type, + value: (el.value || '').replace(/\\d{10,30}/g, (x) => '****' + x.slice(-4)), + placeholder: el.placeholder || '', + ariaLabel: el.getAttribute('aria-label') || '', + })), + }; + return { + ok: true, + begin: dates[0].value, + end: dates[1].value, + points: dates.map((el) => { + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }), + }; + })()`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId); + if (result.exceptionDetails) throw new Error('设置网商银行日期失败'); + value = result.result?.value; + if (value?.ok) break; + await sleep(700); + } + if (!value?.ok) throw new Error(value?.error || '设置网商银行查询条件失败'); + + const clickPoint = async (point) => { + await session.cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: 'left', + clickCount: 1, + }, session.sessionId); + await session.cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: 'left', + clickCount: 1, + }, session.sessionId); + }; + const targetMonthLabel = `${targetDate.slice(0, 4)}年${Number(targetDate.slice(5, 7))}月`; + const selectCalendarDate = async (inputIndex) => { + const points = await session.cdp.send('Runtime.evaluate', { + expression: `([...document.querySelectorAll('input')] + .filter((el) => (el.offsetWidth || el.offsetHeight) + && /^\\d{4}-\\d{2}-\\d{2}$/.test(el.value || '')) + .map((el) => { + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }))`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId).then((response) => response.result?.value || []); + const point = points[inputIndex]; + if (!point) throw new Error('未找到网商银行日期输入框'); + await clickPoint(point); + + let header = ''; + for (let attempt = 0; attempt < 8 && header === ''; attempt++) { + await sleep(350); + header = await session.cdp.send('Runtime.evaluate', { + expression: `([...document.querySelectorAll('.ant-picker-header-view')] + .find((el) => el.offsetWidth || el.offsetHeight)?.innerText || '')`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId).then((response) => String(response.result?.value || '').trim()); + } + if (header === '') { + await clickPoint(points[inputIndex]); + await sleep(700); + header = await session.cdp.send('Runtime.evaluate', { + expression: `([...document.querySelectorAll('.ant-picker-header-view')] + .find((el) => el.offsetWidth || el.offsetHeight)?.innerText || '')`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId).then((response) => String(response.result?.value || '').trim()); + } + if (header === '') throw new Error('网商银行日期面板未打开'); + + for (let attempt = 0; attempt < 8; attempt++) { + if (header.includes(targetMonthLabel)) break; + + const match = /(\d{4})年\s*(\d{1,2})月/.exec(header); + if (!match) throw new Error('网商银行日期面板月份无法识别:' + header.slice(0, 80)); + const shown = Number(match[1]) * 12 + Number(match[2]); + const target = Number(targetDate.slice(0, 4)) * 12 + Number(targetDate.slice(5, 7)); + const selector = shown < target + ? '.ant-picker-header-next-btn' + : '.ant-picker-header-prev-btn'; + if (!await clickVisibleSelector(session.cdp, session.sessionId, selector)) { + throw new Error('网商银行日期面板无法切换月份'); + } + await sleep(350); + header = await session.cdp.send('Runtime.evaluate', { + expression: `([...document.querySelectorAll('.ant-picker-header-view')] + .find((el) => el.offsetWidth || el.offsetHeight)?.innerText || '')`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId).then((response) => String(response.result?.value || '').trim()); + } + + const cell = await session.cdp.send('Runtime.evaluate', { + expression: `(() => { + const el = [...document.querySelectorAll('td[title="${targetDate}"]')] + .find((cell) => (cell.offsetWidth || cell.offsetHeight) + && /ant-picker-cell-in-view/.test(cell.className || '')); + const rect = el?.getBoundingClientRect(); + return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null; + })()`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId).then((response) => response.result?.value); + if (!cell) throw new Error(`网商银行日期面板找不到 ${targetDate}`); + await clickPoint(cell); + await sleep(600); + }; + + const currentDates = () => session.cdp.send('Runtime.evaluate', { + expression: `([...document.querySelectorAll('input')] + .filter((el) => /^\\d{4}-\\d{2}-\\d{2}$/.test(el.value || '')) + .map((el) => el.value))`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId).then((response) => response.result?.value || []); + + if (value.begin !== targetDate) await selectCalendarDate(0); + let dates = await currentDates(); + if (dates[1] !== targetDate) await selectCalendarDate(1); + dates = await currentDates(); + if (dates[0] !== targetDate || dates[1] !== targetDate) { + throw new Error('网商银行日期选择未生效'); + } + if (!await clickVisibleText(session.cdp, session.sessionId, '查询')) { + throw new Error('未找到查询按钮'); + } + await sleep(2500); +} + +async function waitForQueryResult(session) { + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + const result = await session.cdp.send('Runtime.evaluate', { + expression: `(() => { + const text = document.body?.innerText || ''; + const match = text.match(/共(?:计)?\\s*(\\d+)\\s*条/); + const money = (pattern) => { + const hit = text.match(pattern); + return hit ? Number(String(hit[1]).replace(/,/g, '')) : null; + }; + return { + count: match ? Number(match[1]) : null, + countText: match?.[0] || '', + incomeTotal: money(/借方总额[((](?:收入|收)[))][::\\s]*[¥¥]?\\s*([\\d,.]+)/), + expenseTotal: money(/贷方总额[((](?:支出|支)[))][::\\s]*[¥¥]?\\s*([\\d,.]+)/), + empty: /暂无数据|没有找到符合条件|未查询到交易|未查询到相关内容/.test(text), + loading: /加载中|查询中/.test(text), + queryBusy: [...document.querySelectorAll('button')] + .some((el) => /^查询$/.test((el.innerText || '').replace(/\\s+/g, '')) && el.disabled), + dateValues: [...document.querySelectorAll('input')] + .filter((el) => /^\\d{4}-\\d{2}-\\d{2}$/.test(el.value || '')) + .map((el) => el.value) + }; + })()`, + awaitPromise: true, + returnByValue: true, + }, session.sessionId); + const value = result.result?.value; + if (value && !value.loading && !value.queryBusy + && value.dateValues.includes(targetDate) + && (value.count !== null || value.empty)) { + return { ...value, count: value.count ?? 0 }; + } + await sleep(900); + } + throw new Error('网商银行交易明细查询超时'); +} + +async function clickDownloadAll(session, expectedRows) { + if (await clickVisibleText( + session.cdp, + session.sessionId, + `下载所有${expectedRows}条明细`, + { contains: true, selectors: 'button,[role="button"],a,span,div' }, + )) { + return true; + } + + return clickVisibleText( + session.cdp, + session.sessionId, + '下载所有', + { contains: true, selectors: 'button,[role="button"],a,span,div' }, + ); +} + +async function maybeOpenDownloadTask(session) { + return clickVisibleText( + session.cdp, + session.sessionId, + '下载任务', + { selectors: 'button,[role="button"],a,span,div' }, + ); +} + +async function confirmDownloadDialog(session) { + for (let attempt = 0; attempt < 20; attempt++) { + if (await clickVisibleText( + session.cdp, + session.sessionId, + '立即下载', + { selectors: 'button,[role="button"],a,span,div' }, + )) { + return true; + } + await sleep(400); + } + + return false; +} + +async function exportAccount(account, index) { + const accountDirectory = path.join( + accountRoot, + `${String(index + 1).padStart(2, '0')}-${account.bank_account.slice(-4)}`, + ); + const downloadDirectory = path.join(accountDirectory, 'downloads'); + fs.mkdirSync(downloadDirectory, { recursive: true }); + + const session = await launchAccountSession(account, { headless: !interactive }); + try { + await session.login({ manualVerification: interactive }); + const bank = await session.openBank(); + await openTransactionDetail(bank.cdp, bank.sessionId, account); + await session.saveAuth(); + let identity = null; + for (let attempt = 0; attempt < 20; attempt++) { + identity = await bank.cdp.send('Runtime.evaluate', { + expression: `(() => { + const text = document.body?.innerText || ''; + const compact = text.replace(/\\s+/g, ''); + const hit = text.match(/账户\\s*((?:\\d[\\s-]*){12,30})/); + return { + account: hit ? hit[1].replace(/\\D/g, '') : '', + companyPresent: compact.includes(${JSON.stringify(account.company.replace(/\s+/g, ''))}), + }; + })()`, + awaitPromise: true, + returnByValue: true, + }, bank.sessionId).then((response) => response.result?.value || {}); + if (identity.account) break; + await sleep(500); + } + if (!identity.companyPresent) { + throw new Error('网商银行页面公司名称与配置不一致'); + } + if (!identity.account) { + throw new Error('无法读取网商银行页面账号'); + } + if (identity.account !== account.bank_account) { + console.log( + `${accountLabel(account)} 账号配置与实际不一致,本次按页面账号 ****${identity.account.slice(-4)} 入账`, + ); + } + const effectiveBankAccount = identity.account; + if (probe) { + const inspection = await inspectTransactionPage(bank.cdp, bank.sessionId); + const screenshot = path.join(accountDirectory, 'probe.png'); + await shot(bank.cdp, bank.sessionId, screenshot); + fs.writeFileSync( + path.join(accountDirectory, 'probe.json'), + JSON.stringify({ ...inspection, screenshot }, null, 2), + 'utf8', + ); + console.log(JSON.stringify({ + company: account.company, + account: maskAccount(effectiveBankAccount), + countText: inspection.countText, + inputs: inspection.inputs, + buttons: inspection.buttons, + screenshot, + })); + return { skip: true }; + } + + await session.setDownloadDir(downloadDirectory); + await setTargetDateAndQuery(bank); + const query = await waitForQueryResult(bank); + console.log(`${accountLabel(account)} ${targetDate} 页面记录 ${query.count} 笔`); + if (query.count === 0) { + return { + company: account.company, + account: effectiveBankAccount, + account_short: account.account_short, + expected_rows: 0, + expected_income: 0, + expected_expense: 0, + }; + } + + const beforeFiles = new Set(filesIn(downloadDirectory)); + if (!await clickDownloadAll(bank, query.count)) { + throw new Error('未找到“下载所有明细”按钮'); + } + await sleep(700); + await confirmDownloadDialog(bank); + let downloaded = await waitForDownload(downloadDirectory, beforeFiles, 60000); + if (!downloaded && await maybeOpenDownloadTask(bank)) { + await sleep(700); + await confirmDownloadDialog(bank); + downloaded = await waitForDownload(downloadDirectory, beforeFiles, 45000); + } + if (!downloaded) throw new Error('网商银行明细下载超时'); + + const downloadedName = safeName( + `${account.company}-${query.count}笔${path.extname(downloaded)}`, + `${maskAccount(account.bank_account)}${path.extname(downloaded)}`, + ); + const finalFile = path.join(accountDirectory, downloadedName); + fs.copyFileSync(downloaded, finalFile); + const relative = path.relative(runDirectory, finalFile).replaceAll('\\', '/'); + return { + company: account.company, + account: effectiveBankAccount, + account_short: account.account_short, + expected_rows: query.count, + expected_income: query.incomeTotal, + expected_expense: query.expenseTotal, + file: relative, + }; + } finally { + await session.close(); + } +} + +const successes = []; +const failures = []; +try { + for (let index = 0; index < accounts.length; index++) { + const account = accounts[index]; + let lastError = null; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + console.log(`[${index + 1}/${accounts.length}] ${accountLabel(account)} 第 ${attempt} 次执行`); + const result = await exportAccount(account, index); + if (!result.skip) successes.push(result); + lastError = null; + break; + } catch (error) { + lastError = error; + console.error(`${accountLabel(account)} 失败:${sanitize(error?.message || error)}`); + if (attempt < 2) await sleep(3000); + } + } + if (lastError) { + failures.push({ + company: account.company, + account_last4: account.bank_account.slice(-4), + error: sanitize(lastError?.message || lastError), + }); + } + } +} finally { + if (!probe) { + fs.writeFileSync(manifestFile, JSON.stringify({ + version: 1, + date: targetDate, + generated_at: new Date().toISOString(), + accounts: successes, + }, null, 2), 'utf8'); + } + fs.writeFileSync(stateFile, JSON.stringify({ + ok: failures.length === 0 && successes.length > 0, + date: targetDate, + accounts: successes.length, + failed: failures.length, + failures, + manifest: manifestFile, + cutoff_at: new Date().toISOString(), + }, null, 2), 'utf8'); +} + +if (!probe && failures.length > 0) process.exitCode = 1; diff --git a/scripts/wangshang-bank/import-accounts.ps1 b/scripts/wangshang-bank/import-accounts.ps1 new file mode 100644 index 00000000..797e9ee6 --- /dev/null +++ b/scripts/wangshang-bank/import-accounts.ps1 @@ -0,0 +1,144 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [string] $Workbook = 'D:\zyc\网商银行账号密码.xlsx', + [string] $Output, + [string] $Python +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +if ([string]::IsNullOrWhiteSpace($Output)) { + $Output = Join-Path $root 'runtime\private\wangshang-bank-accounts.json' +} +$workbookPath = (Resolve-Path -LiteralPath $Workbook -ErrorAction Stop).Path +$outputPath = [IO.Path]::GetFullPath($Output) +$outputDirectory = [IO.Path]::GetDirectoryName($outputPath) +[IO.Directory]::CreateDirectory($outputDirectory) | Out-Null + +if ([string]::IsNullOrWhiteSpace($Python)) { + $candidates = @( + $env:CODEX_PYTHON, + (Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'), + (Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source) + ) | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) -and (Test-Path -LiteralPath $_ -PathType Leaf) + } + $Python = $candidates | Where-Object { $_ -notmatch 'WindowsApps' } | Select-Object -First 1 +} +if ([string]::IsNullOrWhiteSpace($Python) -or -not (Test-Path -LiteralPath $Python -PathType Leaf)) { + throw '未找到可用的 Python;请通过 -Python 指定包含 openpyxl 的 python.exe' +} + +$ignored = & git -C $root check-ignore --quiet $outputPath +if ($LASTEXITCODE -ne 0) { + throw '网商银行账号配置目录未写入项目忽略范围,拒绝保存凭据' +} + +$env:WANGSHANG_BANK_WORKBOOK = $workbookPath +$env:WANGSHANG_BANK_OUTPUT = $outputPath +$env:PYTHONUTF8 = '1' +try { + @' +import json +import os +import re +from pathlib import Path + +from openpyxl import load_workbook + +workbook = Path(os.environ["WANGSHANG_BANK_WORKBOOK"]) +output = Path(os.environ["WANGSHANG_BANK_OUTPUT"]) +sheet = load_workbook(workbook, read_only=True, data_only=True)["Sheet1"] +headers = [str(cell.value or "").strip() for cell in sheet[1]] + +aliases = { + "company": ["公司名称"], + "alipay_account": ["支付宝账号"], + "alipay_password": ["支付宝密码"], + "bank_account": ["网商银行账号"], +} +indexes = {} +for key, names in aliases.items(): + for name in names: + if name in headers: + indexes[key] = headers.index(name) + break + if key not in indexes: + raise RuntimeError(f"账号表缺少字段:{names[0]}") + +def account_short(company): + value = re.sub(r"[\s ]+", "", company) + value = re.sub( + r"^(?:北京市?|上海市?|天津市?|重庆市?|杭州市?|深圳市?|青岛市?|广州市?|" + r"株洲市?|义乌市?|南召县|永康市?|常州市?)", + "", + value, + ) + pattern = re.compile( + r"(?:电子商务咨询服务|电子商务|网络科技|科技|商贸|贸易|家居|家具|" + r"咨询服务|实业|供应链管理|有限公司|有限责任公司)$" + ) + while True: + shortened = pattern.sub("", value) + if shortened == value: + break + value = shortened + return value + "网商银行" + +accounts = [] +for row in sheet.iter_rows(min_row=2, values_only=True): + def value(key): + index = indexes[key] + return "" if index >= len(row) or row[index] is None else str(row[index]).strip() + + company = value("company") + alipay_account = value("alipay_account") + alipay_password = value("alipay_password") + bank_account = re.sub(r"\D", "", value("bank_account")) + if not any([company, alipay_account, alipay_password, bank_account]): + continue + if not company or not alipay_account or not alipay_password or len(bank_account) < 6: + raise RuntimeError(f"第 {len(accounts) + 2} 行账号资料不完整") + accounts.append({ + "company": company, + "alipay_account": alipay_account, + "alipay_password": alipay_password, + "bank_account": bank_account, + "account_short": account_short(company), + }) + +if not accounts: + raise RuntimeError("账号表没有可导入的数据") + +temporary = output.with_suffix(output.suffix + ".tmp") +temporary.write_text( + json.dumps({"version": 1, "accounts": accounts}, ensure_ascii=False, indent=2), + encoding="utf-8", +) +os.replace(temporary, output) +print(f"accounts={len(accounts)} output={output}") +'@ | & $Python - + if ($LASTEXITCODE -ne 0) { + throw '读取网商银行账号表失败' + } +} finally { + Remove-Item Env:WANGSHANG_BANK_WORKBOOK -ErrorAction SilentlyContinue + Remove-Item Env:WANGSHANG_BANK_OUTPUT -ErrorAction SilentlyContinue +} + +$acl = Get-Acl -LiteralPath $outputPath +$acl.SetAccessRuleProtection($true, $false) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$rule = New-Object Security.AccessControl.FileSystemAccessRule( + $identity, + 'FullControl', + 'Allow' +) +$acl.AddAccessRule($rule) +Set-Acl -LiteralPath $outputPath -AclObject $acl + +Write-Host ('已生成网商银行私有配置:' + $outputPath) diff --git a/scripts/wangshang-bank/install-daily-task.ps1 b/scripts/wangshang-bank/install-daily-task.ps1 new file mode 100644 index 00000000..9e93b3ab --- /dev/null +++ b/scripts/wangshang-bank/install-daily-task.ps1 @@ -0,0 +1,73 @@ +#Requires -Version 5.1 + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [ValidatePattern('^([01]\d|2[0-3]):[0-5]\d$')] + [string] $Time = '10:00', + + [string] $TaskName = 'CaiWuXiTong-WangshangBankDaily' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$cmdPath = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot 'run-wangshang-daily.cmd')).Path +$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$arguments = @( + '/d', + '/c', + ('""' + $cmdPath + '""') +) -join ' ' + +if (-not $PSCmdlet.ShouldProcess($TaskName, "register daily Wangshang Bank 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 = 'PT2H' + $definition.Settings.MultipleInstances = 2 + $definition.Settings.RestartCount = 1 + $definition.Settings.RestartInterval = 'PT15M' + + $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 = $env:ComSpec + $action.Arguments = $arguments + $action.WorkingDirectory = $PSScriptRoot + + $registered = $rootFolder.RegisterTaskDefinition( + $TaskName, + $definition, + 6, + $null, + $null, + 3, + $null + ) + Write-Host "已安装每日网商银行账单任务:$TaskName,执行时间每天 $Time。" + [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($registered) +} finally { + foreach ($item in @($definition, $rootFolder, $service)) { + if ($null -ne $item -and [Runtime.InteropServices.Marshal]::IsComObject($item)) { + [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($item) + } + } +} diff --git a/scripts/wangshang-bank/open-login.mjs b/scripts/wangshang-bank/open-login.mjs new file mode 100644 index 00000000..6df1d4c6 --- /dev/null +++ b/scripts/wangshang-bank/open-login.mjs @@ -0,0 +1,29 @@ +import { + launchAccountSession, + openTransactionDetail, +} from './session.mjs'; +import { accountLabel, loadAccounts } from './config.mjs'; + +const arg = (name, fallback = null) => { + const hit = process.argv.find((item) => item.startsWith(`--${name}=`)); + return hit ? hit.slice(name.length + 3) : fallback; +}; +const companyFilter = String(arg('company', '')).trim(); +const accounts = loadAccounts().filter((account) => + !companyFilter || account.company.includes(companyFilter) +); +if (accounts.length === 0) throw new Error('没有匹配的网商银行账户'); + +for (const account of accounts) { + console.log(`打开 ${accountLabel(account)} 的登录窗口。请完成短信、扫码或图形验证;成功后脚本会自动继续。`); + const session = await launchAccountSession(account, { headless: false }); + try { + await session.login({ manualVerification: true }); + const bank = await session.openBank(); + await openTransactionDetail(bank.cdp, bank.sessionId, account); + await session.saveAuth(); + console.log(`${accountLabel(account)} 登录成功,已能进入交易明细页,登录状态已保存。`); + } finally { + await session.close(); + } +} diff --git a/scripts/wangshang-bank/open-login.ps1 b/scripts/wangshang-bank/open-login.ps1 new file mode 100644 index 00000000..bf34f5bf --- /dev/null +++ b/scripts/wangshang-bank/open-login.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param( + [string] $Company +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$command = Get-Command node.exe -ErrorAction SilentlyContinue | Select-Object -First 1 +$node = if ($null -ne $command) { $command.Source } else { $null } +if ([string]::IsNullOrWhiteSpace($node)) { + $candidates = @( + (Join-Path $env:ProgramFiles 'nodejs\node.exe'), + (Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe') + ) + $node = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 +} +if ([string]::IsNullOrWhiteSpace($node)) { + throw '未找到 Node.js' +} + +$arguments = @((Join-Path $PSScriptRoot 'open-login.mjs')) +if (-not [string]::IsNullOrWhiteSpace($Company)) { + $arguments += ('--company=' + $Company) +} + +& $node @arguments +exit $LASTEXITCODE diff --git a/scripts/wangshang-bank/run-wangshang-daily.cmd b/scripts/wangshang-bank/run-wangshang-daily.cmd new file mode 100644 index 00000000..4dd47f61 --- /dev/null +++ b/scripts/wangshang-bank/run-wangshang-daily.cmd @@ -0,0 +1,10 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +set "LOG_DIR=%SCRIPT_DIR%..\..\runtime\private\wangshang-bank-exports\logs" +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" +set "TASK_LOG=%LOG_DIR%\task-host.log" + +"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%run-wangshang-daily.ps1" %* >> "%TASK_LOG%" 2>&1 +exit /b %ERRORLEVEL% diff --git a/scripts/wangshang-bank/run-wangshang-daily.ps1 b/scripts/wangshang-bank/run-wangshang-daily.ps1 new file mode 100644 index 00000000..fea5d717 --- /dev/null +++ b/scripts/wangshang-bank/run-wangshang-daily.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param( + [ValidatePattern('^\d{4}-\d{2}-\d{2}$')] + [string] $Date, + + [string] $Company, + + [string] $Server = '218.93.190.75', + + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string] $SshUser = 'root', + + [switch] $DryRun, + + [switch] $SkipImport +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$configFile = Join-Path $root 'runtime\private\wangshang-bank-accounts.json' +$exportScript = Join-Path $PSScriptRoot 'export-daily.mjs' +$logDir = Join-Path $root 'runtime\private\wangshang-bank-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 + Write-Host $line + Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 +} + +function Resolve-Node { + $command = Get-Command node.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $command -and -not [string]::IsNullOrWhiteSpace($command.Source)) { + return $command.Source + } + $candidates = @( + (Join-Path $env:ProgramFiles 'nodejs\node.exe'), + (Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe') + ) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + + throw '未找到 Node.js,请安装 Node.js 后重新触发任务' +} + +if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { + throw '缺少 runtime/private/wangshang-bank-accounts.json,请先导入网商银行账号表' +} +if ([string]::IsNullOrWhiteSpace($Date)) { + $Date = (Get-Date).Date.AddDays(-1).ToString('yyyy-MM-dd') +} + +$node = Resolve-Node +$runStamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$runDirectory = Join-Path $root ('runtime\private\wangshang-bank-exports\' + $Date + '\run-' + $runStamp) +New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null +$exportArgs = @( + $exportScript, + ('--date=' + $Date), + ('--out=' + (Split-Path -Parent $runDirectory)), + ('--run-dir=' + $runDirectory) +) +if (-not [string]::IsNullOrWhiteSpace($Company)) { + $exportArgs += ('--company=' + $Company) +} + +Write-Log ('开始导出网商银行 ' + $Date + ' 交易明细') +$exportOutput = (& $node @exportArgs 2>&1 | Out-String) +$exportExit = $LASTEXITCODE +if (-not [string]::IsNullOrWhiteSpace($exportOutput)) { + Write-Log ('导出输出:' + ($exportOutput.Trim() -replace "`r?`n", ' | ')) +} + +$manifestFile = Join-Path $runDirectory 'manifest.json' +$stateFile = Join-Path $runDirectory 'last-export.json' +if (-not (Test-Path -LiteralPath $stateFile -PathType Leaf)) { + throw '网商银行导出没有生成状态文件;未写入财务台账' +} +$state = [IO.File]::ReadAllText($stateFile, [Text.Encoding]::UTF8) | ConvertFrom-Json +if ([string]$state.date -ne $Date) { + throw '网商银行导出状态日期与本次任务不一致' +} +if ([int]$state.accounts -le 0) { + throw '本次没有成功导出的网商银行账户;未写入财务台账' +} +if (-not (Test-Path -LiteralPath $manifestFile -PathType Leaf)) { + throw '网商银行导出缺少 manifest.json;未写入财务台账' +} +Write-Log ('导出完成:成功账户 {0},失败账户 {1}' -f $state.accounts, $state.failed) + +$hadFailures = $exportExit -ne 0 -or [int]$state.failed -gt 0 +foreach ($failure in @($state.failures)) { + if ($null -ne $failure) { + Write-Log ('账户失败:{0},尾号 {1},原因 {2}' -f $failure.company, $failure.account_last4, $failure.error) + } +} + +if ($SkipImport) { + Write-Log '已指定 -SkipImport,未写入财务台账' + if ($hadFailures) { exit 1 } + return +} + +$bundleFile = Join-Path $runDirectory ('wangshang-bank-' + $Date + '.zip') +Compress-Archive -Path (Join-Path $runDirectory '*') -DestinationPath $bundleFile -Force +if (-not (Test-Path -LiteralPath $bundleFile -PathType Leaf)) { + throw '网商银行导入批次压缩失败' +} + +. (Join-Path $root 'scripts\deploy-auth.ps1') +$remoteFile = '/opt/caiwuxitong/runtime/private/wangshang-bank-imports/' + $Date + '/wangshang-bank-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.zip' +$auth = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser +try { + $target = "${SshUser}@${Server}" + $remoteDirectory = [IO.Path]::GetDirectoryName($remoteFile).Replace('\', '/') + & ssh @($auth.Options) $target ("install -d -m 700 '" + $remoteDirectory + "'") + if ($LASTEXITCODE -ne 0) { throw '创建网商银行账单远程目录失败' } + + & scp @($auth.Options) $bundleFile ("${target}:" + $remoteFile) + if ($LASTEXITCODE -ne 0) { throw '上传网商银行账单批次失败' } + + $dryRunFlag = if ($DryRun) { ' --dry-run' } else { '' } + $command = @( + 'set -eu', + ('sh /opt/caiwuxitong/run-finance-batch.sh finance-ledger:import-wangshang-bank ' + + "'$remoteFile' --date='$Date'" + $dryRunFlag), + ("rm -f '" + $remoteFile + "'") + ) -join '; ' + $importOutput = (& ssh @($auth.Options) $target $command 2>&1 | Out-String) + $importExit = $LASTEXITCODE + if (-not [string]::IsNullOrWhiteSpace($importOutput)) { + Write-Log ('导入输出:' + ($importOutput.Trim() -replace "`r?`n", ' | ')) + } + if ($importExit -ne 0) { + throw '网商银行账单导入失败;原有动账明细未改变' + } +} finally { + Stop-CaiwuDeploymentAuth $auth +} + +Write-Log ($(if ($DryRun) { '网商银行账单校验完成' } else { '网商银行账单已接入财务台账动账明细' })) +if ($hadFailures) { + Write-Log '本次有账户导出失败;成功账户已导入,失败账户保持原数据' + exit 1 +} diff --git a/scripts/wangshang-bank/session.mjs b/scripts/wangshang-bank/session.mjs new file mode 100644 index 00000000..6bca93ac --- /dev/null +++ b/scripts/wangshang-bank/session.mjs @@ -0,0 +1,383 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + CDP, + evaluate, + goto, + launchEdge, + openPage, + setDownloadDir, + shot, + sleep, +} from '../erp-daily-bills/cdp.mjs'; +import { accountLabel, profileDirectory } from './config.mjs'; +import { restoreCookies, saveCookies } from './cookie-store.mjs'; + +const PORT = 9336; +const ALIPAY_HOME = 'https://b.alipay.com/page/home'; + +export async function launchAccountSession(account, { headless = true } = {}) { + const profileDir = profileDirectory(account); + fs.mkdirSync(profileDir, { recursive: true }); + const { child, version } = await launchEdge({ + port: PORT, + profileDir, + headless, + windowSize: '2048,1300', + forceDeviceScaleFactor: 1, + }); + const cdp = await CDP.connect(version.webSocketDebuggerUrl); + await restoreCookies(cdp, account); + const { sessionId } = await openPage(cdp, 'about:blank'); + + return { + account, + cdp, + sessionId, + async close() { + try { + await Promise.race([ + cdp.send('Browser.close'), + sleep(1500).then(() => { + throw new Error('浏览器关闭超时'); + }), + ]); + } catch { + // Browser.close may close the WebSocket before its reply is delivered. + } + cdp.close(); + child.kill(); + }, + async login(options = {}) { + await loginAlipay(cdp, sessionId, account, options); + }, + async openBank() { + return openEnterpriseBank(cdp, sessionId, account); + }, + async setDownloadDir(directory) { + await setDownloadDir(cdp, directory); + }, + async screenshot(file) { + return shot(cdp, sessionId, file); + }, + async saveAuth() { + return saveCookies(cdp, account); + }, + }; +} + +export async function pageInfo(cdp, sessionId) { + 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"]'), + bodyReady: document.readyState === 'complete' + })`); +} + +export async function clickVisibleText( + cdp, + sessionId, + text, + { contains = false, selectors = 'a,button,li,[role="button"],span,div' } = {}, +) { + const target = await evaluate(cdp, sessionId, `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight); + const expected = ${JSON.stringify(String(text).replace(/\s+/g, ''))}; + const contains = ${contains ? 'true' : 'false'}; + const matches = [...document.querySelectorAll(${JSON.stringify(selectors)})] + .filter(visible) + .map((el) => ({ + rect: el.getBoundingClientRect(), + text: (el.innerText || el.value || '').replace(/\\s+/g, ''), + })) + .filter((item) => contains ? item.text.includes(expected) : item.text === expected) + .filter((item) => item.rect.width > 0 && item.rect.height > 0) + .sort((left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height); + const hit = matches[0]?.rect; + return hit ? { x: hit.x + hit.width / 2, y: hit.y + hit.height / 2 } : null; + })()`); + if (!target) return false; + + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: target.x, + y: target.y, + button: 'left', + clickCount: 1, + }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: target.x, + y: target.y, + button: 'left', + clickCount: 1, + }, sessionId); + + return true; +} + +export async function clickVisibleSelector(cdp, sessionId, selector) { + const target = await evaluate(cdp, sessionId, `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight); + const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(visible); + const rect = el?.getBoundingClientRect(); + return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null; + })()`); + if (!target) return false; + + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: target.x, + y: target.y, + button: 'left', + clickCount: 1, + }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: target.x, + y: target.y, + button: 'left', + clickCount: 1, + }, sessionId); + + return true; +} + +async function waitFor(check, description, timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await check(); + if (result) return result; + await sleep(800); + } + throw new Error(description + '超时'); +} + +export async function loginAlipay(cdp, sessionId, account, { manualVerification = false } = {}) { + await goto(cdp, sessionId, ALIPAY_HOME); + await sleep(1800); + let state = await pageInfo(cdp, sessionId); + + if (!state.hasUser && !state.hasPassword && /b\.alipay\.com/.test(state.url)) { + return state; + } + + const loginTab = await evaluate(cdp, sessionId, `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight); + const target = [...document.querySelectorAll('a,button,li,[role="button"],span,div')] + .filter(visible) + .find((el) => (el.innerText || '').replace(/\\s+/g, '') === '账密登录'); + const rect = target?.getBoundingClientRect(); + return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null; + })()`); + if (loginTab) { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: loginTab.x, + y: loginTab.y, + button: 'left', + clickCount: 1, + }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: loginTab.x, + y: loginTab.y, + button: 'left', + clickCount: 1, + }, sessionId); + } + await sleep(900); + + const form = await evaluate(cdp, sessionId, `(() => { + const user = document.querySelector('#J-input-user'); + const passwordBox = document.querySelector('#J-password'); + const button = [...document.querySelectorAll('button,input[type="submit"],[role="button"],a')] + .find((el) => !el.disabled && ( + /^登录$/.test((el.innerText || '').replace(/\\s+/g, '')) + || /^登录$/.test(el.value || '') + || el.id === 'J-login-simulate-btn' + )); + const point = (el) => { + const rect = el?.getBoundingClientRect(); + return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null; + }; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (user) { + setter ? setter.call(user, '') : (user.value = ''); + user.dispatchEvent(new Event('input', { bubbles: true })); + user.dispatchEvent(new Event('change', { bubbles: true })); + } + return user && passwordBox && button + ? { user: point(user), passwordBox: point(passwordBox), submit: point(button) } + : null; + })()`); + if (!form) { + throw new Error(accountLabel(account) + ' 未找到支付宝账号密码登录表单'); + } + const clickPoint = async (point) => { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: 'left', + clickCount: 1, + }, sessionId); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: 'left', + clickCount: 1, + }, sessionId); + }; + await clickPoint(form.user); + await cdp.send('Input.insertText', { text: account.alipay_account }, sessionId); + await sleep(1800); + + const refreshedForm = await evaluate(cdp, sessionId, `(() => { + const point = (selector) => { + const el = document.querySelector(selector); + const rect = el?.getBoundingClientRect(); + return rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : null; + }; + return { + passwordBox: point('#J-password'), + submit: point('#J-login-simulate-btn'), + }; + })()`); + if (refreshedForm?.passwordBox && refreshedForm?.submit) { + await clickPoint(refreshedForm.passwordBox); + await cdp.send('Input.insertText', { text: account.alipay_password }, sessionId); + await sleep(400); + await clickPoint(refreshedForm.submit); + } + + let manualPromptShown = false; + state = await waitFor(async () => { + const current = await pageInfo(cdp, sessionId); + if (current.url.includes('b.alipay.com') && !current.hasUser && !current.hasPassword) { + return current; + } + if (/请在下图依次点击|短信验证|扫码验证|身份验证|安全验证|请完成验证码|请完成图片验证|请拖动滑块/.test(current.text)) { + if (manualVerification) { + if (!manualPromptShown) { + console.log(`请在打开的 Edge 窗口中完成 ${accountLabel(account)} 的安全验证,最多等待 5 分钟。`); + manualPromptShown = true; + } + return null; + } + throw new Error(accountLabel(account) + ' 需要人工完成支付宝安全验证,请先运行 open-login.mjs'); + } + return null; + }, accountLabel(account) + ' 支付宝登录', manualVerification ? 300000 : 45000); + + await waitFor(async () => { + const current = await pageInfo(cdp, sessionId); + return /企业网银|网商银行企业账户/.test(current.text) ? current : null; + }, accountLabel(account) + ' 支付宝企业工作台', 30000); + await sleep(2500); + + return state; +} + +export async function openEnterpriseBank(cdp, sessionId, account) { + const beforeTargets = new Set( + (await cdp.send('Target.getTargets')).targetInfos.map((target) => target.targetId), + ); + let clicked = await clickVisibleText(cdp, sessionId, '企业网银', { + selectors: 'a,button,[role="button"],span,div', + }); + if (!clicked) { + for (let attempt = 0; attempt < 20 && !clicked; attempt++) { + if (attempt % 4 === 0) { + await clickVisibleText(cdp, sessionId, '网商银行企业账户', { + contains: true, + selectors: 'a,button,[role="button"],li,span,div', + }); + } + await sleep(500); + clicked = await clickVisibleText(cdp, sessionId, '企业网银', { + selectors: 'a,button,[role="button"],span,div', + }); + } + if (!clicked) throw new Error(accountLabel(account) + ' 未找到“企业网银”入口'); + } + + return waitFor(async () => { + const current = await pageInfo(cdp, sessionId); + if (/b\.mybank\.cn/.test(current.url)) { + return { cdp, sessionId, targetId: null }; + } + + const targets = (await cdp.send('Target.getTargets')).targetInfos; + const target = targets.find((item) => + item.type === 'page' + && !beforeTargets.has(item.targetId) + && /b\.mybank\.cn/.test(item.url || '') + ); + if (!target) return null; + + const attached = await cdp.send('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }); + await cdp.send('Page.enable', {}, attached.sessionId); + await cdp.send('Runtime.enable', {}, attached.sessionId); + return { cdp, sessionId: attached.sessionId, targetId: target.targetId }; + }, accountLabel(account) + ' 进入企业网银', 60000); +} + +export async function openTransactionDetail(cdp, sessionId, account) { + await waitFor(async () => { + const state = await pageInfo(cdp, sessionId); + return /账务查询/.test(state.text) ? state : null; + }, accountLabel(account) + ' 加载企业网银首页', 45000); + await sleep(1800); + + let clicked = await clickVisibleText(cdp, sessionId, '交易明细查询'); + + if (!clicked) { + for (let attempt = 0; attempt < 20 && !clicked; attempt++) { + if (attempt % 4 === 0) { + await clickVisibleText(cdp, sessionId, '账务查询'); + } + await sleep(500); + clicked = await clickVisibleText(cdp, sessionId, '交易明细查询'); + } + } + if (!clicked) throw new Error(accountLabel(account) + ' 未找到“交易明细查询”入口'); + + await waitFor(async () => { + const state = await pageInfo(cdp, sessionId); + return /交易明细/.test(state.text) && /查询/.test(state.text) ? state : null; + }, accountLabel(account) + ' 加载交易明细页', 45000); +} + +export async function inspectTransactionPage(cdp, sessionId) { + return evaluate(cdp, sessionId, `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight); + const text = (el) => (el.innerText || el.value || '').replace(/\\s+/g, ' ').trim(); + return { + url: location.href, + title: document.title, + inputs: [...document.querySelectorAll('input')].filter(visible).map((el, index) => ({ + index, + type: el.type || '', + value: el.value || '', + placeholder: el.placeholder || '', + ariaLabel: el.getAttribute('aria-label') || '', + disabled: !!el.disabled, + className: String(el.className || '').slice(0, 120), + })), + buttons: [...document.querySelectorAll('button,[role="button"],a')] + .filter(visible) + .map(text) + .filter(Boolean) + .slice(0, 80), + countText: (document.body?.innerText || '').match(/共\\s*\\d+\\s*条/)?.[0] || '', + }; + })()`); +} diff --git a/tests/Feature/Console/ImportWangshangBankBillsTest.php b/tests/Feature/Console/ImportWangshangBankBillsTest.php new file mode 100644 index 00000000..7d83398e --- /dev/null +++ b/tests/Feature/Console/ImportWangshangBankBillsTest.php @@ -0,0 +1,192 @@ +table('fin_accounts')->insert([ + 'company_name' => '测试网商公司', + 'account_short' => '测试网商银行', + 'account_no' => '8888000012345678', + 'account_type' => '网商银行', + 'current_balance' => 1000, + ]); + $connection->table('fin_account_opening')->insert([ + 'account_short' => '测试网商银行', + 'opening_date' => '2026-10-01', + 'opening_balance' => 1000, + ]); + $connection->table('fin_transactions')->insert([ + 'txn_date' => '2026-11-01', + 'account_short' => '测试网商银行', + 'detail' => '旧版本', + 'income' => 999, + 'expense' => 0, + 'source' => WangshangBankBillImporter::SOURCE, + 'source_value' => '8888000012345678', + 'process_type' => WangshangBankBillImporter::PROCESS_TYPE, + 'row_key' => hash('sha256', 'old-wangshang-row'), + ]); + $bundle = $this->bundle([ + ['交易时间', '账务流水号', '交易名称', '对方户名', '借方金额(收)', '贷方金额(支)', '交易状态'], + ['2026-11-01 09:10:00', '202611010001', '他行转入', '甲公司', '100.00', '-', '成功'], + ['2026-11-01 10:20:00', '202611010002', '转账汇款', '乙公司', '-', '20.00', '成功'], + ['2026-10-31 10:20:00', '202610310001', '非目标日期', '丙公司', '999.00', '-', '成功'], + ], expectedIncome: 100, expectedExpense: 20); + + $this->artisan('finance-ledger:import-wangshang-bank', [ + 'path' => $bundle, + '--date' => '2026-11-01', + ])->assertExitCode(0); + + $this->assertDatabaseCount('fin_transactions', 2, 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'account_short' => '测试网商银行', + 'approval_no' => '202611010001', + 'income' => 100, + 'expense' => 0, + 'source' => WangshangBankBillImporter::SOURCE, + 'source_value' => '8888000012345678', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'approval_no' => '202611010002', + 'income' => 0, + 'expense' => 20, + ], 'business_mirror'); + $this->assertDatabaseMissing('fin_transactions', ['detail' => '旧版本'], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => WangshangBankBillImporter::SOURCE, + 'source_value' => '8888000012345678', + 'account_short' => '测试网商银行', + 'txn_count' => 2, + 'income_sum' => 100, + 'expense_sum' => 20, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_daily_flows', [ + 'flow_date' => '2026-11-01', + 'account_short' => '测试网商银行', + 'opening_balance' => 1000, + 'inflow' => 100, + 'outflow' => 20, + 'closing_balance' => 1080, + ], 'business_mirror'); + } + + public function test_row_count_mismatch_keeps_existing_transactions(): void + { + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + 'company_name' => '测试网商公司', + 'account_short' => '测试网商银行', + 'account_no' => '8888000012345678', + 'account_type' => '网商银行', + ]); + $connection->table('fin_transactions')->insert([ + 'txn_date' => '2026-11-01', + 'account_short' => '测试网商银行', + 'detail' => '应保留', + 'income' => 1, + 'expense' => 0, + 'source' => WangshangBankBillImporter::SOURCE, + 'source_value' => '8888000012345678', + 'process_type' => WangshangBankBillImporter::PROCESS_TYPE, + 'row_key' => hash('sha256', 'keep-wangshang-row'), + ]); + $bundle = $this->bundle([ + ['交易时间', '账务流水号', '交易名称', '借方金额(收)', '贷方金额(支)'], + ['2026-11-01 09:10:00', '202611010001', '他行转入', '100.00', '-'], + ], expectedRows: 2); + + $this->withExceptionHandling()->artisan('finance-ledger:import-wangshang-bank', [ + 'path' => $bundle, + '--date' => '2026-11-01', + ])->assertExitCode(1); + + $this->assertDatabaseHas('fin_transactions', ['detail' => '应保留'], 'business_mirror'); + } + + public function test_imports_transactions_when_the_account_master_has_not_been_extended(): void + { + $bundle = $this->bundle([ + ['交易时间', '账务流水号', '交易名称', '借方金额(收)', '贷方金额(支)'], + ['2026-11-01 09:10:00', '202611010003', '他行转入', '100.00', '-'], + ], expectedRows: 1, expectedIncome: 100, expectedExpense: 0); + + $this->artisan('finance-ledger:import-wangshang-bank', [ + 'path' => $bundle, + '--date' => '2026-11-01', + '--dry-run' => true, + ])->assertExitCode(0); + $this->assertDatabaseCount('fin_transactions', 0, 'business_mirror'); + + $this->artisan('finance-ledger:import-wangshang-bank', [ + 'path' => $bundle, + '--date' => '2026-11-01', + ])->assertExitCode(0); + + $this->assertDatabaseHas('fin_transactions', [ + 'account_short' => '测试网商银行', + 'approval_no' => '202611010003', + 'income' => 100, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => WangshangBankBillImporter::SOURCE, + 'source_value' => '8888000012345678', + 'account_short' => '测试网商银行', + ], 'business_mirror'); + } + + /** + * @param list> $rows + */ + private function bundle( + array $rows, + int $expectedRows = 2, + ?float $expectedIncome = null, + ?float $expectedExpense = null, + ): string + { + $directory = sys_get_temp_dir() . '/wangshang-test-' . bin2hex(random_bytes(6)); + mkdir($directory, 0700, true); + $csv = $directory . '/bills.csv'; + $stream = fopen($csv, 'wb'); + foreach ($rows as $row) { + fputcsv($stream, $row); + } + fclose($stream); + + file_put_contents($directory . '/manifest.json', json_encode([ + 'date' => '2026-11-01', + 'accounts' => [[ + 'company' => '测试网商公司', + 'account' => '8888000012345678', + 'account_short' => '测试网商银行', + 'expected_rows' => $expectedRows, + 'expected_income' => $expectedIncome, + 'expected_expense' => $expectedExpense, + 'file' => 'bills.csv', + ]], + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + + $zipPath = sys_get_temp_dir() . '/wangshang-test-' . bin2hex(random_bytes(6)) . '.zip'; + $zip = new ZipArchive(); + $this->assertTrue($zip->open($zipPath, ZipArchive::CREATE) === true); + $zip->addFile($directory . '/manifest.json', 'manifest.json'); + $zip->addFile($csv, 'bills.csv'); + $zip->close(); + + unlink($csv); + unlink($directory . '/manifest.json'); + rmdir($directory); + + return $zipPath; + } +}