feat(shopro_huifu): 银行支行数据导入功能增强

- 添加增量导入模式,默认跳过已存在的联行号,不清空数据库
- 新增 --update-existing 选项支持覆盖更新已存在记录
- 新增 --check 选项用于校验数据库中的重复联行号
- 改进导入统计信息,区分新增、更新、跳过、重复等状态
- 优化 CSV 文件解析,支持多种字段别名映射
- 添加文件内重复检测和 BOM 头处理
- 更新 README 文档说明新的使用方式和命令选项
```
这个提交包含在:
可乐加冰
2026-09-30 16:09:40 +08:00
父节点 21d6da86f1
当前提交 5dbf6a346b
共有 3 个文件被更改,包括 203 次插入 和 34 次删除

查看文件

@@ -102,8 +102,11 @@ php think shopro_huifu:import-bank-branch --download
# https://hf-mirror.com/datasets/gweesin/CNAPS/resolve/main/cnaps.csv
php think shopro_huifu:import-bank-branch --file=addons/shopro_huifu/data/cnaps.csv
# 可选:清空后全量重导
php think shopro_huifu:import-bank-branch --download --truncate
# 增量导入(默认跳过已存在联行号,不清库)
php think shopro_huifu:import-bank-branch --file=addons/shopro_huifu/data/cnaps.csv
# 校验库内重复
php think shopro_huifu:import-bank-branch --check
# 试跑(不写库)
php think shopro_huifu:import-bank-branch --file=addons/shopro_huifu/data/cnaps.csv --limit=100 --dry-run

查看文件

@@ -19,7 +19,9 @@ class ImportBankBranch extends Command
->addOption('file', 'f', Option::VALUE_OPTIONAL, 'CSV/JSON source file path')
->addOption('download', 'd', Option::VALUE_NONE, 'Download gweesin/CNAPS cnaps.csv then import')
->addOption('seed', null, Option::VALUE_NONE, 'Import bundled data/bank_branches.json seed only')
->addOption('truncate', 't', Option::VALUE_NONE, 'Clear table before import')
->addOption('truncate', 't', Option::VALUE_NONE, 'Clear table before import (optional, default skip existing)')
->addOption('update-existing', 'u', Option::VALUE_NONE, 'Update rows when branch_code already exists')
->addOption('check', 'c', Option::VALUE_NONE, 'Check duplicate branch_code / branch_name in DB only')
->addOption('limit', 'l', Option::VALUE_OPTIONAL, 'Max rows to import (0=all)', 0)
->addOption('batch', 'b', Option::VALUE_OPTIONAL, 'Batch size', 500)
->addOption('format', null, Option::VALUE_OPTIONAL, 'Force format: csv|json|auto', 'auto')
@@ -35,6 +37,10 @@ class ImportBankBranch extends Command
return $this->exportJson($output, $exportJson, (int) $input->getOption('export-limit'));
}
if ((bool) $input->getOption('check')) {
return $this->checkDuplicates($output);
}
try {
Db::execute('SELECT 1 FROM `' . config('database.prefix') . 'shopro_huifu_bank_branch` LIMIT 1');
} catch (\Throwable $e) {
@@ -82,6 +88,7 @@ class ImportBankBranch extends Command
$options = [
'truncate' => (bool) $input->getOption('truncate'),
'update_existing' => (bool) $input->getOption('update-existing'),
'limit' => (int) $input->getOption('limit'),
'batch' => (int) $input->getOption('batch'),
'format' => (string) $input->getOption('format'),
@@ -94,6 +101,8 @@ class ImportBankBranch extends Command
}
if ($options['truncate']) {
$output->writeln('[truncate] 导入前清空旧数据');
} else {
$output->writeln('已存在联行号将自动跳过(可用 --update-existing 覆盖更新)');
}
try {
@@ -104,12 +113,17 @@ class ImportBankBranch extends Command
}
$output->writeln(sprintf(
'完成: 新增 %d,更新 %d,跳过 %d,有效 %d',
'完成: 解析 %d 行,新增 %d,更新 %d,跳过已存在 %d,文件内重复 %d,无效 %d',
$stats['parsed'] ?? 0,
$stats['inserted'],
$stats['updated'],
$stats['skipped'],
$stats['total']
$stats['skipped_existing'] ?? 0,
$stats['duplicate_in_file'] ?? 0,
$stats['skipped_invalid'] ?? 0
));
if (($stats['parsed'] ?? 0) > 1000 && ($stats['inserted'] ?? 0) < 1000 && ($stats['skipped_existing'] ?? 0) < 100) {
$output->writeln('警告: 解析行数很多但新增很少,请确认 CSV 联行号列为 BankCode(非 BankId)');
}
$count = Db::name('shopro_huifu_bank_branch')->where('status', 1)->count();
$output->writeln('当前库内有效支行: ' . $count);
@@ -117,6 +131,44 @@ class ImportBankBranch extends Command
return 0;
}
protected function checkDuplicates(Output $output)
{
try {
$result = HuifuOnboardBankBranchImporter::checkDatabaseDuplicates();
} catch (\Throwable $e) {
$output->error($e->getMessage());
return 1;
}
$output->writeln('当前库内支行总数: ' . $result['total']);
$codeDupes = $result['duplicate_codes'];
if ($codeDupes) {
$output->writeln('发现重复联行号 ' . count($codeDupes) . ' 组(不应出现,表有唯一索引):');
foreach ($codeDupes as $item) {
$output->writeln(' ' . $item['branch_code'] . ' x' . $item['cnt']);
}
} else {
$output->writeln('联行号 branch_code 无重复');
}
$nameDupes = $result['duplicate_names'];
if ($nameDupes) {
$output->writeln('同名不同号(展示前 ' . count($nameDupes) . ' 组,通常可忽略):');
foreach ($nameDupes as $item) {
$output->writeln(sprintf(
' %s(%d 条,%d 个联行号)',
$item['branch_name'],
$item['cnt'],
$item['code_cnt']
));
}
} else {
$output->writeln('支行名称无重复');
}
return 0;
}
protected function exportJson(Output $output, $dest, $limit)
{
try {

查看文件

@@ -8,8 +8,14 @@ class HuifuOnboardBankBranchImporter
{
protected static $areaNameMap;
/** @var array<string, bool> */
protected static $existingCodes;
/** @var array<string, bool> */
protected static $seenCodes;
/**
* @return array{inserted:int,updated:int,skipped:int,total:int}
* @return array{inserted:int,updated:int,skipped_invalid:int,skipped_existing:int,duplicate_in_file:int,parsed:int,total:int}
*/
public static function importFile($file, array $options = [])
{
@@ -27,49 +33,104 @@ class HuifuOnboardBankBranchImporter
$batch = max(50, min(2000, (int) ($options['batch'] ?? 500)));
$dryRun = !empty($options['dry_run']);
$truncate = !empty($options['truncate']);
$updateExisting = !empty($options['update_existing']);
if ($truncate && !$dryRun) {
Db::name('shopro_huifu_bank_branch')->where('id', '>', 0)->delete();
}
self::$existingCodes = null;
self::$seenCodes = [];
if (!$dryRun) {
self::loadExistingCodes();
}
$rows = $format === 'json'
? self::parseJsonFile($file, $limit)
: self::parseCsvFile($file, $limit);
$inserted = 0;
$updated = 0;
$skipped = 0;
$skippedInvalid = 0;
$skippedExisting = 0;
$duplicateInFile = 0;
$buffer = [];
$flushOptions = [
'dry_run' => $dryRun,
'update_existing' => $updateExisting,
];
foreach ($rows as $row) {
$normalized = self::normalizeRow($row);
if (!$normalized) {
$skipped++;
$skippedInvalid++;
continue;
}
$code = $normalized['branch_code'];
if (isset(self::$seenCodes[$code])) {
$duplicateInFile++;
continue;
}
self::$seenCodes[$code] = true;
$buffer[] = $normalized;
if (count($buffer) >= $batch) {
$stats = self::flushBuffer($buffer, $dryRun);
$stats = self::flushBuffer($buffer, $flushOptions);
$inserted += $stats['inserted'];
$updated += $stats['updated'];
$skippedExisting += $stats['skipped_existing'];
$buffer = [];
}
}
if ($buffer) {
$stats = self::flushBuffer($buffer, $dryRun);
$stats = self::flushBuffer($buffer, $flushOptions);
$inserted += $stats['inserted'];
$updated += $stats['updated'];
$skippedExisting += $stats['skipped_existing'];
}
return [
'inserted' => $inserted,
'updated' => $updated,
'skipped' => $skipped,
'skipped_invalid' => $skippedInvalid,
'skipped_existing' => $skippedExisting,
'duplicate_in_file' => $duplicateInFile,
'parsed' => count($rows),
'total' => $inserted + $updated,
];
}
/**
* 校验库内重复联行号 / 同名不同号
*
* @return array{total:int,duplicate_codes:array,duplicate_names:array}
*/
public static function checkDatabaseDuplicates($nameSampleLimit = 10)
{
$nameSampleLimit = max(1, min(50, (int) $nameSampleLimit));
$total = (int) Db::name('shopro_huifu_bank_branch')->count();
$duplicateCodes = Db::name('shopro_huifu_bank_branch')
->field('branch_code, COUNT(*) AS cnt')
->group('branch_code')
->having('cnt > 1')
->select();
$duplicateNames = Db::query(
'SELECT branch_name, COUNT(*) AS cnt, COUNT(DISTINCT branch_code) AS code_cnt '
. 'FROM `' . config('database.prefix') . 'shopro_huifu_bank_branch` '
. 'GROUP BY branch_name HAVING cnt > 1 '
. 'ORDER BY cnt DESC LIMIT ' . $nameSampleLimit
);
return [
'total' => $total,
'duplicate_codes' => $duplicateCodes ?: [],
'duplicate_names' => $duplicateNames ?: [],
];
}
/**
* @return array{inserted:int,updated:int,skipped:int,total:int}
*/
@@ -207,24 +268,35 @@ class HuifuOnboardBankBranchImporter
protected static function normalizeHeaderKey($name)
{
$name = trim((string) $name);
$name = preg_replace('/^\xEF\xBB\xBF/', '', $name);
if ($name === '') {
return '';
}
$lower = strtolower($name);
$aliases = [
'branch_code' => ['branch_code', 'bankid', 'cnaps', 'cnapscode', '联行号', '行号', '开户行联行号', '大额支付行号'],
// gweesin/CNAPS: 联行号在 BankCode,BankId 是银行机构号(多支行共用,不可用)
'branch_code' => ['branch_code', 'bankcode', 'cnaps', 'cnapscode', '联行号', '行号', '开户行联行号', '大额支付行号'],
'branch_name' => ['branch_name', 'lname', 'banknamebranch', '支行名称', '开户行', '开户行支行名称', '支行全称', '网点名称'],
'bank_name' => ['bank_name', 'bankname', 'banknamemain', '银行', '银行名称', '银行简称', '总行名称'],
'bank_name' => ['bank_name', 'banknamemain', '银行', '银行名称', '银行简称', '总行名称'],
'province' => ['province', 'provincename', 'areaprovince', '省份', '省'],
'city' => ['city', 'cityname', 'areacity', '城市', '市', '地区'],
'prov_id' => ['prov_id', 'provincecode', '省码'],
'area_id' => ['area_id', 'citycode', '市码'],
'area_id' => ['area_id', '市码'],
];
foreach ($aliases as $canonical => $list) {
if (in_array($lower, $list, true) || in_array($name, $list, true)) {
return $canonical;
}
}
if ($lower === 'bankname') {
return 'bank_name';
}
if ($lower === 'citycode') {
return 'city_code';
}
if ($lower === 'bankid') {
return 'bank_inst_id';
}
return '';
}
@@ -252,9 +324,7 @@ class HuifuOnboardBankBranchImporter
protected static function normalizeRow(array $row)
{
$branchCode = self::normalizeBranchCode(
$row['branch_code'] ?? ($row['BankId'] ?? ($row['cnapsCode'] ?? ''))
);
$branchCode = self::normalizeBranchCode(self::pickBranchCode($row));
$branchName = trim((string) ($row['branch_name'] ?? ($row['LName'] ?? '')));
if ($branchCode === '' || $branchName === '') {
return null;
@@ -295,6 +365,16 @@ class HuifuOnboardBankBranchImporter
];
}
protected static function pickBranchCode(array $row)
{
foreach (['branch_code', 'BankCode', 'cnapsCode', '联行号'] as $key) {
if (!empty($row[$key])) {
return $row[$key];
}
}
return '';
}
protected static function normalizeBranchCode($code)
{
$code = preg_replace('/\D+/', '', (string) $code);
@@ -418,36 +498,70 @@ class HuifuOnboardBankBranchImporter
return strtolower($level . '|' . HuifuOnboardAreaHelper::toCode($provId) . '|' . $name);
}
protected static function loadExistingCodes()
{
if (self::$existingCodes !== null) {
return;
}
self::$existingCodes = [];
$codes = Db::name('shopro_huifu_bank_branch')->column('branch_code');
foreach ($codes ?: [] as $code) {
self::$existingCodes[(string) $code] = true;
}
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{inserted:int,updated:int}
* @param array{dry_run?:bool,update_existing?:bool} $options
* @return array{inserted:int,updated:int,skipped_existing:int}
*/
protected static function flushBuffer(array $rows, $dryRun = false)
protected static function flushBuffer(array $rows, array $options = [])
{
$dryRun = !empty($options['dry_run']);
$updateExisting = !empty($options['update_existing']);
$inserted = 0;
$updated = 0;
$skippedExisting = 0;
if ($dryRun) {
return ['inserted' => count($rows), 'updated' => 0];
foreach ($rows as $row) {
if (isset(self::$existingCodes[$row['branch_code']])) {
$skippedExisting++;
} else {
$inserted++;
}
}
return ['inserted' => $inserted, 'updated' => 0, 'skipped_existing' => $skippedExisting];
}
$toInsert = [];
foreach ($rows as $row) {
$exists = Db::name('shopro_huifu_bank_branch')->where('branch_code', $row['branch_code'])->find();
if ($exists) {
Db::name('shopro_huifu_bank_branch')->where('id', $exists['id'])->update([
'branch_name' => $row['branch_name'],
'bank_name' => $row['bank_name'],
'prov_id' => $row['prov_id'],
'area_id' => $row['area_id'],
'status' => 1,
]);
$updated++;
$code = $row['branch_code'];
if (isset(self::$existingCodes[$code])) {
if ($updateExisting) {
Db::name('shopro_huifu_bank_branch')->where('branch_code', $code)->update([
'branch_name' => $row['branch_name'],
'bank_name' => $row['bank_name'],
'prov_id' => $row['prov_id'],
'area_id' => $row['area_id'],
'status' => 1,
]);
$updated++;
} else {
$skippedExisting++;
}
continue;
}
Db::name('shopro_huifu_bank_branch')->insert($row);
$inserted++;
$toInsert[] = $row;
self::$existingCodes[$code] = true;
}
return ['inserted' => $inserted, 'updated' => $updated];
if ($toInsert) {
Db::name('shopro_huifu_bank_branch')->insertAll($toInsert);
$inserted = count($toInsert);
}
return ['inserted' => $inserted, 'updated' => $updated, 'skipped_existing' => $skippedExisting];
}
protected static function downloadToFile($url, $dest)