```
feat(shopro_huifu): 银行支行数据导入功能增强 - 添加增量导入模式,默认跳过已存在的联行号,不清空数据库 - 新增 --update-existing 选项支持覆盖更新已存在记录 - 新增 --check 选项用于校验数据库中的重复联行号 - 改进导入统计信息,区分新增、更新、跳过、重复等状态 - 优化 CSV 文件解析,支持多种字段别名映射 - 添加文件内重复检测和 BOM 头处理 - 更新 README 文档说明新的使用方式和命令选项 ```
这个提交包含在:
@@ -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)
|
||||
|
||||
在新工单中引用
屏蔽一个用户