442 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|