CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerWangshangBasicImporter.php

342 lines
12 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use App\Business\XlsxSheetReader;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use Generator;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use RuntimeException;
/**
* 合并导入网商银行基本信息。
*
* 文件中的账户会按网商账号、其次按公司名称匹配现有记录。匹配后只更新本表提供的
* 字段;表中没有的现有账户会保留,避免基础资料调整误删历史流水关联。
*/
class FinanceLedgerWangshangBasicImporter
{
private const MAX_ROWS = 1000;
private const REQUIRED_HEADERS = [
'公司名称',
'账户简称',
'法人',
'账户状态',
'网商账号',
'开户行',
'绑定支付宝',
'是否保留',
'后4位',
'备注',
];
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly XlsxSheetReader $reader,
) {
$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('导入文件不存在。');
}
return $this->connection->transaction(function () use ($path): 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 = [];
$byCompany = [];
foreach ($current as $account) {
$accountNo = $this->normalizeAccountNo($account->account_no ?? null);
if ($accountNo !== '') {
$byAccountNo[$accountNo] = $account;
}
$byCompany[(string) $account->company_name][] = $account;
}
$used = [];
$inserted = 0;
$updated = 0;
$unchanged = 0;
$now = now();
foreach ($this->rows($path) as $row) {
$account = $byAccountNo[$row['account_no']] ?? null;
if ($account !== null && isset($used[(int) $account->id])) {
$account = null;
}
if ($account === null) {
foreach ($byCompany[$row['company_name']] ?? [] as $candidate) {
if (! isset($used[(int) $candidate->id])) {
$account = $candidate;
break;
}
}
}
if ($account !== null) {
$used[(int) $account->id] = true;
$values = $this->updateValues($row, $account);
if ($values === []) {
$unchanged++;
continue;
}
$this->connection->table('fin_accounts')->where('id', $account->id)->update([
...$values,
'updated_at' => $now,
]);
$updated++;
continue;
}
$accountShort = $row['account_short'] !== ''
? $this->uniqueAccountShort($row['account_short'])
: $this->uniqueAccountShort($this->deriveAccountShort($row['company_name']));
$this->ensureCompany($row['company_name'], $now);
$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'],
'bank_name' => $row['bank_name'],
'bound_alipay' => $row['bound_alipay'],
'bank_city_branch' => null,
'account_type' => '网商银行',
'company_plan' => $row['company_plan'],
'account_should_keep' => $row['account_should_keep'],
'account_last4' => $row['account_last4'],
'current_balance' => 0,
'blocked_amount' => 0,
'daily_available' => 0,
'remark' => $row['remark'],
'created_at' => $now,
'updated_at' => $now,
]);
$inserted++;
}
if ($inserted + $updated + $unchanged === 0) {
throw new RuntimeException('表格中没有可导入的网商银行账户。');
}
return [
'total' => $inserted + $updated + $unchanged,
'inserted' => $inserted,
'updated' => $updated,
'unchanged' => $unchanged,
'preserved' => count(array_filter(
$current,
static fn (object $account): bool => ! isset($used[(int) $account->id]),
)),
];
});
}
/**
* @return Generator<int,array{
* company_name:string,account_short:string,legal_representative:?string,
* account_no:string,bank_name:?string,bound_alipay:?string,
* company_plan:?string,account_should_keep:?string,
* account_last4:?string,remark:?string
* }>
*/
private function rows(string $path): Generator
{
$seenAccountNos = [];
$line = 1;
foreach ($this->reader->rows($path, self::REQUIRED_HEADERS) as $row) {
$line++;
if ($line - 1 > self::MAX_ROWS) {
throw new RuntimeException('单次最多导入 ' . self::MAX_ROWS . ' 条网商银行账户。');
}
$value = static fn (string $header): mixed => $row[$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;
$accountType = '网商银行';
yield [
'company_name' => $company,
'account_short' => $this->text($value('账户简称')),
'legal_representative' => $this->nullableText($value('法人')),
'account_no' => $accountNo,
'bank_name' => $this->nullableText($value('开户行')),
'bound_alipay' => $this->nullableText($value('绑定支付宝')),
'company_plan' => $this->companyPlan($value('账户状态')),
'account_should_keep' => $this->shouldKeep($value('是否保留')),
'account_last4' => FinanceLedgerAccountType::last4(
$accountType,
$accountNo,
$this->text($value('后4位')),
) ?: null,
'remark' => $this->nullableText($value('备注')),
];
}
}
/**
* @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
: '网商银行',
'legal_representative' => $row['legal_representative'] ?? $account->legal_representative,
'bank_name' => $row['bank_name'] ?? $account->bank_name,
'bound_alipay' => $row['bound_alipay'] ?? $account->bound_alipay,
'company_plan' => $row['company_plan'] ?? $account->company_plan,
'account_should_keep' => $row['account_should_keep'] ?? $account->account_should_keep,
'account_last4' => $row['account_last4'] ?? $account->account_last4,
'remark' => $row['remark'] ?? $account->remark,
];
$changed = [];
foreach ($values as $field => $value) {
$current = $account->{$field} ?? null;
if ((string) ($value ?? '') !== (string) ($current ?? '')) {
$changed[$field] = $value;
}
}
return $changed;
}
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 = mb_substr(trim($name) !== '' ? trim($name) : $companyName, 0, 18);
return $name . '网商';
}
private function companyPlan(mixed $value): ?string
{
$text = $this->text($value);
if ($text === '') {
return null;
}
if (str_contains($text, '注销') || str_contains($text, '不保留')) {
return '待注销';
}
if (str_contains($text, '保留')) {
return '保留';
}
return $text;
}
private function shouldKeep(mixed $value): ?string
{
$text = $this->text($value);
if ($text === '') {
return null;
}
if (in_array($text, ['注销', '不保留', '不需要', '否'], true)) {
return FinanceLedgerAccountShouldKeep::NO;
}
$normalized = FinanceLedgerAccountShouldKeep::normalize($text);
return in_array($normalized, FinanceLedgerAccountShouldKeep::OPTIONS, true)
? $normalized
: null;
}
private function normalizeAccountNo(mixed $value): string
{
return 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;
}
}