feat: 接入网商银行每日动账明细 (#831)

Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
This commit is contained in:
92451 2026-11-02 20:58:42 +08:00 committed by yutu
parent 46f62575ff
commit 67c85678e6
17 changed files with 2494 additions and 1 deletions

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\WangshangBankBillImporter;
use Throwable;
class ImportWangshangBankBills extends Command
{
protected $signature = 'finance-ledger:import-wangshang-bank
{path : 网商银行批次 ZIP 或 manifest.json}
{--date= : 账单日期 YYYY-MM-DD,默认前一天}
{--dry-run : 只校验,不写入财务台账}';
protected $description = '批量导入网商银行交易明细到财务台账动账明细';
public function handle(WangshangBankBillImporter $importer): int
{
$date = trim((string) ($this->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;
}
}

View File

@ -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",

View File

@ -0,0 +1,683 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
use ZipArchive;
class WangshangBankBillImporter
{
public const SOURCE = 'wangshang-bank';
public const PROCESS_TYPE = '网商银行';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->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<array<string,mixed>>,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<string,mixed> */
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<string,mixed> $manifest
* @return list<array<string,mixed>>
*/
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<array<string,mixed>>
*/
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<array<string,mixed>> $entries
* @return array<string,mixed>
*/
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<string>,1:list<list<string>>}
*/
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<string>,1:list<string>}
*/
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<string>,1:list<list<string>>}
*/
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<list<string>> $matrix
* @return array{0:list<string>,1:list<list<string>>}
*/
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<string> $headers @return array<string,int> */
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<string> $headers @param list<string> $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);
}
}

View File

@ -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' });

View File

@ -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。

View File

@ -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)})`;
}

View File

@ -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))

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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();
}
}

View File

@ -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

View File

@ -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%

View File

@ -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
}

View File

@ -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] || '',
};
})()`);
}

View File

@ -0,0 +1,192 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\WangshangBankBillImporter;
use Tests\Feature\FeatureTestCase;
use ZipArchive;
class ImportWangshangBankBillsTest extends FeatureTestCase
{
public function test_imports_a_batch_and_replaces_the_same_account_date(): void
{
$connection = DB::connection('business_mirror');
$connection->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<list<string>> $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;
}
}