feat: 接入网商银行每日动账明细 (#831)
Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
This commit is contained in:
parent
46f62575ff
commit
67c85678e6
57
app/Console/Commands/ImportWangshangBankBills.php
Normal file
57
app/Console/Commands/ImportWangshangBankBills.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
683
modules/EcommerceFinance/Services/WangshangBankBillImporter.php
Normal file
683
modules/EcommerceFinance/Services/WangshangBankBillImporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
47
scripts/wangshang-bank/README.md
Normal file
47
scripts/wangshang-bank/README.md
Normal 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。
|
||||
73
scripts/wangshang-bank/config.mjs
Normal file
73
scripts/wangshang-bank/config.mjs
Normal 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)})`;
|
||||
}
|
||||
34
scripts/wangshang-bank/cookie-protection.ps1
Normal file
34
scripts/wangshang-bank/cookie-protection.ps1
Normal 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))
|
||||
91
scripts/wangshang-bank/cookie-store.mjs
Normal file
91
scripts/wangshang-bank/cookie-store.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
493
scripts/wangshang-bank/export-daily.mjs
Normal file
493
scripts/wangshang-bank/export-daily.mjs
Normal 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;
|
||||
144
scripts/wangshang-bank/import-accounts.ps1
Normal file
144
scripts/wangshang-bank/import-accounts.ps1
Normal 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)
|
||||
73
scripts/wangshang-bank/install-daily-task.ps1
Normal file
73
scripts/wangshang-bank/install-daily-task.ps1
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
scripts/wangshang-bank/open-login.mjs
Normal file
29
scripts/wangshang-bank/open-login.mjs
Normal 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();
|
||||
}
|
||||
}
|
||||
28
scripts/wangshang-bank/open-login.ps1
Normal file
28
scripts/wangshang-bank/open-login.ps1
Normal 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
|
||||
10
scripts/wangshang-bank/run-wangshang-daily.cmd
Normal file
10
scripts/wangshang-bank/run-wangshang-daily.cmd
Normal 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%
|
||||
152
scripts/wangshang-bank/run-wangshang-daily.ps1
Normal file
152
scripts/wangshang-bank/run-wangshang-daily.ps1
Normal 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
|
||||
}
|
||||
383
scripts/wangshang-bank/session.mjs
Normal file
383
scripts/wangshang-bank/session.mjs
Normal 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] || '',
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
192
tests/Feature/Console/ImportWangshangBankBillsTest.php
Normal file
192
tests/Feature/Console/ImportWangshangBankBillsTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user