CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerAlipayBasicImporter.php
shanshi 10fd8bfdb4 feat: 支持支付宝基本信息导入 (#1164)
支持支付宝基本信息表格合并导入,并保存法人、账户类型、商户号和店铺字段。
2026-11-13 18:38:20 +08:00

442 lines
16 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
/**
* 合并导入支付宝基本信息。
*
* 优先按支付宝账号匹配现有记录,其次按商户号,最后只在公司候选唯一时
* 按公司匹配。未出现在表格中的现有支付宝账户会保留,避免历史流水失联。
*/
class FinanceLedgerAlipayBasicImporter
{
private const MAX_ROWS = 1000;
private const REQUIRED_HEADERS = [
'公司名称',
'账户简称',
'法人',
'账户类型',
'商户号',
'支付宝账号',
'账户状态',
'是否保留',
'店铺',
'备注',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{total:int,inserted:int,updated:int,unchanged:int,preserved:int}
*/
public function import(string $path): array
{
if (! is_file($path)) {
throw new RuntimeException('导入文件不存在。');
}
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
try {
$sheet = $spreadsheet->getSheetByName('支付宝基本信息')
?? $spreadsheet->getSheet(0);
$rows = $this->rows($sheet);
} finally {
$spreadsheet->disconnectWorksheets();
}
return $this->connection->transaction(function () use ($rows): array {
$current = $this->connection->table('fin_accounts')
->where(function ($scope): void {
$scope->where('account_short', 'like', '%支付宝%')
->orWhere('account_type', 'like', '%支付宝%');
})
->orderBy('id')
->lockForUpdate()
->get()
->all();
$byAccountNo = [];
$byMerchantNo = [];
$byCompany = [];
foreach ($current as $account) {
$accountNo = $this->normalizeAccountNo($account->account_no ?? null);
if ($accountNo !== '') {
$byAccountNo[$accountNo] = $account;
}
$merchantNo = $this->normalizeAccountNo($account->alipay_merchant_no ?? null);
if ($merchantNo !== '') {
$byMerchantNo[$merchantNo] = $account;
}
$byCompany[(string) $account->company_name][] = $account;
}
$matches = [];
$unmatched = [];
foreach ($rows as $index => $row) {
$account = $byAccountNo[$row['account_no']] ?? null;
if ($account === null && $row['merchant_no'] !== '') {
$account = $byMerchantNo[$row['merchant_no']] ?? null;
}
if ($account !== null) {
$matches[$index] = $account;
} else {
$unmatched[$index] = $row;
}
}
$used = [];
foreach ($matches as $account) {
$used[(int) $account->id] = true;
}
$unmatchedByCompany = [];
foreach ($unmatched as $index => $row) {
$unmatchedByCompany[$row['company_name']][] = $index;
}
foreach ($unmatchedByCompany as $companyName => $indexes) {
$candidates = array_values(array_filter(
$byCompany[$companyName] ?? [],
static fn (object $account): bool => ! isset($used[(int) $account->id]),
));
if (count($indexes) !== 1 || count($candidates) !== 1) {
continue;
}
$index = $indexes[0];
$account = $candidates[0];
$matches[$index] = $account;
$used[(int) $account->id] = true;
unset($unmatched[$index]);
}
$inserted = 0;
$updated = 0;
$unchanged = 0;
$now = now();
$actor = (string) (user()?->name ?? '支付宝基本信息导入');
foreach ($rows as $index => $row) {
$account = $matches[$index] ?? null;
if ($account !== null) {
$values = $this->updateValues($row, $account);
if ($values !== []) {
$this->connection->table('fin_accounts')->where('id', $account->id)->update([
...$values,
'updated_at' => $now,
]);
$updated++;
} else {
$unchanged++;
}
$this->updateStatus((string) $account->account_short, $row['status'], $actor, $now);
continue;
}
$accountShort = $this->uniqueAccountShort(
$row['account_short'] !== ''
? $row['account_short']
: $this->deriveAccountShort($row['company_name']),
);
$this->connection->table('fin_accounts')->insert([
'company_name' => $row['company_name'],
'account_short' => $accountShort,
'legal_representative' => $row['legal_representative'],
'region' => null,
'account_no' => $row['account_no'],
'alipay_merchant_no' => $row['merchant_no'] !== '' ? $row['merchant_no'] : null,
'bank_name' => '/',
'bank_city_branch' => null,
'alipay_store_name' => $row['store_name'],
'account_type' => FinanceLedgerAccountType::ALIPAY,
'alipay_account_kind' => $row['account_kind'],
'company_plan' => $row['company_plan'],
'account_should_keep' => $row['account_should_keep'],
'current_balance' => 0,
'blocked_amount' => 0,
'daily_available' => 0,
'remark' => $row['remark'],
'created_at' => $now,
'updated_at' => $now,
]);
$this->ensureCompany($row['company_name'], $now);
$this->updateStatus($accountShort, $row['status'], $actor, $now);
$inserted++;
}
return [
'total' => count($rows),
'inserted' => $inserted,
'updated' => $updated,
'unchanged' => $unchanged,
'preserved' => count(array_filter(
$current,
static fn (object $account): bool => ! isset($used[(int) $account->id]),
)),
];
});
}
/**
* @return list<array{
* company_name:string,account_short:string,legal_representative:?string,
* account_no:string,merchant_no:string,account_kind:string,
* status:?string,company_plan:string,account_should_keep:string,
* store_name:?string,remark:?string
* }>
*/
private function rows(Worksheet $sheet): array
{
$lastColumnIndex = Coordinate::columnIndexFromString($sheet->getHighestDataColumn());
$lastColumn = Coordinate::stringFromColumnIndex($lastColumnIndex);
$headers = $sheet->rangeToArray('A1:' . $lastColumn . '1')[0] ?? [];
$indexes = [];
foreach ($headers as $index => $header) {
$key = $this->text($header);
if ($key !== '') {
$indexes[$key] = $index;
}
}
$missing = array_values(array_diff(self::REQUIRED_HEADERS, array_keys($indexes)));
if ($missing !== []) {
throw new RuntimeException('表头缺少:' . implode('、', $missing));
}
$highestRow = $sheet->getHighestDataRow();
if ($highestRow - 1 > self::MAX_ROWS) {
throw new RuntimeException('单次最多导入 ' . self::MAX_ROWS . ' 条支付宝账户。');
}
$rows = [];
$seenAccountNos = [];
$seenMerchantNos = [];
foreach ($sheet->rangeToArray('A2:' . $lastColumn . $highestRow, null, false, false, false) as $index => $row) {
$line = $index + 2;
$value = static fn (string $header): mixed => $row[$indexes[$header]] ?? null;
$company = $this->text($value('公司名称'));
$accountNo = $this->normalizeAccountNo($value('支付宝账号'));
if ($company === '' && $accountNo === '') {
continue;
}
if ($company === '') {
throw new RuntimeException("第 {$line} 行缺少公司名称。");
}
if ($accountNo === '') {
throw new RuntimeException("第 {$line} 行缺少支付宝账号。");
}
if (isset($seenAccountNos[$accountNo])) {
throw new RuntimeException("第 {$line} 行支付宝账号与第 {$seenAccountNos[$accountNo]} 行重复。");
}
$seenAccountNos[$accountNo] = $line;
$merchantNo = $this->normalizeAccountNo($value('商户号'));
if ($merchantNo !== '' && isset($seenMerchantNos[$merchantNo])) {
throw new RuntimeException("第 {$line} 行商户号与第 {$seenMerchantNos[$merchantNo]} 行重复。");
}
if ($merchantNo !== '') {
$seenMerchantNos[$merchantNo] = $line;
}
[$companyPlan, $accountShouldKeep] = $this->planning($value('是否保留'), $line);
$rows[] = [
'company_name' => $company,
'account_short' => $this->text($value('账户简称')),
'legal_representative' => $this->nullableText($value('法人')),
'account_no' => $accountNo,
'merchant_no' => $merchantNo,
'account_kind' => $this->nullableText($value('账户类型')),
'status' => $this->status($value('账户状态'), $line),
'company_plan' => $companyPlan,
'account_should_keep' => $accountShouldKeep,
'store_name' => $this->nullableText($value('店铺')),
'remark' => $this->nullableText($value('备注')),
];
}
if ($rows === []) {
throw new RuntimeException('表格中没有可导入的支付宝账户。');
}
return $rows;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function updateValues(array $row, object $account): array
{
$values = [
'company_name' => $row['company_name'],
'account_no' => $row['account_no'],
'account_type' => trim((string) ($account->account_type ?? '')) !== ''
? (string) $account->account_type
: FinanceLedgerAccountType::ALIPAY,
'alipay_merchant_no' => $row['merchant_no'] !== ''
? $row['merchant_no']
: ($account->alipay_merchant_no ?? null),
'alipay_account_kind' => $row['account_kind'] ?? ($account->alipay_account_kind ?? null),
'alipay_store_name' => $row['store_name'] ?? ($account->alipay_store_name ?? null),
'legal_representative' => $row['legal_representative'] ?? ($account->legal_representative ?? null),
'company_plan' => $row['company_plan'],
'account_should_keep' => $row['account_should_keep'],
'remark' => $row['remark'] ?? ($account->remark ?? null),
];
$changed = [];
foreach ($values as $field => $value) {
$current = $account->{$field} ?? null;
if ((string) ($value ?? '') !== (string) ($current ?? '')) {
$changed[$field] = $value;
}
}
return $changed;
}
/** @return array{0:string,1:string} */
private function planning(mixed $value, int $line): array
{
$text = $this->text($value);
if (in_array($text, ['保留', '是', '需要保留'], true)) {
return ['保留', FinanceLedgerAccountShouldKeep::YES];
}
if (in_array($text, ['不保留', '否', '注销', '待注销'], true)) {
return ['待注销', FinanceLedgerAccountShouldKeep::NO];
}
throw new RuntimeException("第 {$line} 行“是否保留”只能填写保留或不保留。");
}
private function status(mixed $value, int $line): ?string
{
$text = $this->text($value);
if ($text === '') {
return null;
}
$status = array_search($text, FinanceLedgerAccountStatus::OPTIONS, true);
if ($status !== false) {
return $status;
}
if (array_key_exists($text, FinanceLedgerAccountStatus::OPTIONS)) {
return $text;
}
throw new RuntimeException("第 {$line} 行账户状态不在允许范围内。");
}
private function updateStatus(string $accountShort, ?string $status, string $actor, mixed $now): void
{
if ($status === null) {
return;
}
$statuses = $this->connection->table('fin_account_statuses');
$existing = $statuses->where('account_short', $accountShort)->exists();
$values = [
'status' => $status,
'updated_by' => $actor,
'updated_at' => $now,
];
if ($existing) {
$statuses->where('account_short', $accountShort)->update($values);
} else {
$statuses->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
}
}
private function ensureCompany(string $companyName, mixed $now): void
{
if ($this->connection->table('fin_companies')->where('name', $companyName)->exists()) {
return;
}
$this->connection->table('fin_companies')->insert([
'name' => $companyName,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function uniqueAccountShort(string $base): string
{
$base = mb_substr($this->text($base), 0, 90);
$candidate = $base;
$suffix = 1;
while ($this->connection->table('fin_accounts')->where('account_short', $candidate)->exists()) {
$suffix++;
$candidate = $base . $suffix;
}
return $candidate;
}
private function deriveAccountShort(string $companyName): string
{
$name = preg_replace(
'/^(?:常州市?|天津市?|上海市?|深圳市?|杭州市?|义乌市?|南召县?|佛山市?|苏州市?|南京市?|青岛市?|株洲市?|广州市?|永康市?|武进区|临沂市?|滕州市?)/u',
'',
$companyName,
) ?? $companyName;
$name = preg_replace('/^支付宝/u', '', $name) ?? $name;
$name = preg_replace(
'/(?:有限责任公司|股份有限公司|有限公司|个人独资|商贸行|家具有限公司|家具店|公司)$/u',
'',
$name,
) ?? $name;
$name = mb_substr(trim($name) !== '' ? trim($name) : $companyName, 0, 18);
return $name . '支付宝';
}
private function normalizeAccountNo(mixed $value): string
{
return mb_strtolower(preg_replace('/[\s-]+/u', '', $this->text($value)) ?? '');
}
private function text(mixed $value): string
{
if ($value === null) {
return '';
}
$text = trim((string) $value);
if (in_array($text, ['#N/A', '#DIV/0!', '#VALUE!', '#REF!', 'None'], true)) {
return '';
}
return preg_replace('/\s+/u', ' ', $text) ?? $text;
}
private function nullableText(mixed $value): ?string
{
$text = $this->text($value);
return $text !== '' ? $text : null;
}
}