CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerBaseImporter.php

317 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
/**
* 导入财务台账的基础数据:账户、店铺、公司和期初余额。
*
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验、后4位
* 规范化和全量覆盖,不推断其他业务口径;导入完成后由计算器重算流水。
*/
class FinanceLedgerBaseImporter
{
public function import(string $path, ?string $openingDate = null): array
{
if (! is_file($path)) {
throw new RuntimeException('基础数据文件不存在:' . $path);
}
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
try {
$accounts = $this->accounts($spreadsheet->getSheetByName('账户总表'));
$shops = $this->shops($spreadsheet->getSheetByName('店铺台账'));
$companies = $this->companies($spreadsheet->getSheetByName('公司台账'));
$openingDate = $openingDate ?: (string) config('finance_ledger.opening_date', '2026-10-17');
$opening = $this->opening($spreadsheet->getSheetByName('每日资金流水'), $openingDate);
} finally {
$spreadsheet->disconnectWorksheets();
}
if ($accounts === []) {
throw new RuntimeException('账户总表没有可导入的数据。');
}
$accountKeys = array_fill_keys(array_map(
static fn (array $row): string => (string) $row['account_short'],
$accounts,
), true);
$missing = [];
foreach ($opening as $row) {
if (! isset($accountKeys[$row['account_short']])) {
$missing[] = $row['account_short'];
}
}
if ($missing !== []) {
throw new RuntimeException('期初账户在账户总表中不存在:' . implode('、', array_slice(array_unique($missing), 0, 10)));
}
$accountBalance = round(array_sum(array_column($accounts, 'current_balance')), 2);
$openingBalance = round(array_sum(array_column($opening, 'opening_balance')), 2);
if (abs($accountBalance - $openingBalance) > 0.01) {
throw new RuntimeException(sprintf(
'期初合计 %.2f 与账户余额合计 %.2f 不一致。',
$openingBalance,
$accountBalance,
));
}
$now = now();
$connection = $this->connection();
$connection->transaction(function () use ($connection, $accounts, $shops, $companies, $opening, $now): void {
$connection->table('fin_account_opening')->delete();
$connection->table('fin_shops')->delete();
$connection->table('fin_companies')->delete();
$connection->table('fin_accounts')->delete();
$this->insert($connection, 'fin_accounts', $accounts, $now);
$this->insert($connection, 'fin_shops', $shops, $now);
$this->insert($connection, 'fin_companies', $companies, $now);
$this->insert($connection, 'fin_account_opening', $opening, $now);
});
return [
'accounts' => count($accounts),
'shops' => count($shops),
'companies' => count($companies),
'opening_rows' => count($opening),
'opening_sum' => $openingBalance,
'account_balance_sum' => $accountBalance,
'opening_date' => $openingDate,
];
}
protected function accounts(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「账户总表」工作表。');
}
$lastColumn = Coordinate::columnIndexFromString($sheet->getHighestDataColumn());
$headers = $sheet->rangeToArray('A1:' . Coordinate::stringFromColumnIndex($lastColumn) . '1')[0] ?? [];
$headerIndexes = [];
foreach ($headers as $index => $header) {
$key = (string) preg_replace('/\s+/u', '', trim((string) $header));
if ($key !== '') {
$headerIndexes[$key] = $index;
}
}
$rows = [];
foreach ($this->rows($sheet, 2, $lastColumn) as $row) {
$value = static function (string $header, int $legacyIndex) use ($row, $headerIndexes): mixed {
return $row[$headerIndexes[$header] ?? $legacyIndex] ?? null;
};
$company = $this->text($value('公司名称', 0));
$short = $this->text($value('账户简称', 1));
if ($company === '' || $short === '') {
continue;
}
$keepIndex = $headerIndexes['账户是否保留'] ?? null;
$accountNo = $this->text($value('账号', 2));
$accountType = $this->text($value('账户类型', 5));
$rows[] = [
'company_name' => $company,
'account_short' => $short,
'region' => null,
'account_no' => $accountNo,
'bank_name' => $this->text($value('开户行', 3)),
'bank_city_branch' => $this->text($value('开户城市/网点', 4)),
'account_type' => $accountType,
'company_plan' => $this->text($value('公司规划', 6)),
'account_should_keep' => FinanceLedgerAccountShouldKeep::normalize(
$keepIndex === null ? null : $this->text($row[$keepIndex] ?? null),
),
'account_last4' => FinanceLedgerAccountType::last4(
$accountType,
$accountNo,
$this->text($value('后4位', 7)),
),
'single_limit' => $this->text($value('单笔限额', 8)),
'daily_limit' => $this->text($value('日限额', 9)),
'monthly_limit' => $this->text($value('月限额', 10)),
'yearly_limit' => $this->text($value('年限额', 11)),
'current_balance' => $this->number($value('可用金额', 12)),
'blocked_amount' => $this->number($value('不可用金额', 13)),
'daily_available' => $this->number($value('每日可出', 14)),
'clearing_days' => $this->nullableNumber($value('预计出清天数', 15)),
'raise_target' => $this->text($value('提额目标', 16)),
'raise_status' => $this->text($value('提额状态', 17)),
'raise_actions' => $this->text($value('提额动作', 18)),
'done_actions' => $this->text($value('已做动作', 19)),
'next_action' => $this->text($value('下一步', 20)),
'remark' => $this->text($value('备注', 21)),
'owner' => $this->text($value('责任人', 22)),
];
}
return $rows;
}
private function shops(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「店铺台账」工作表。');
}
$statusMap = ['正常' => '在营', '关闭中' => '暂停', '已关闭' => '已注销'];
$rows = [];
foreach ($this->rows($sheet, 6, 8) as $row) {
$name = $this->text($row[0] ?? null);
if ($name === '') {
continue;
}
$status = $this->text($row[4] ?? null);
$rows[] = [
'shop_name' => $name,
'platform' => $this->text($row[1] ?? null),
'company_name' => $this->text($row[2] ?? null),
'account_short' => $this->text($row[3] ?? null),
'status' => $statusMap[$status] ?? $status,
'payout_account' => $this->text($row[5] ?? null),
'shop_balance' => $this->number($row[6] ?? null),
'remark' => $this->text($row[7] ?? null),
];
}
return $rows;
}
private function companies(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「公司台账」工作表。');
}
$rows = [];
foreach ($this->rows($sheet, 6, 10) as $row) {
$name = $this->text($row[0] ?? null);
if ($name === '') {
continue;
}
$rows[] = [
'name' => $name,
'register_place' => $this->text($row[1] ?? null),
'business_type' => $this->text($row[2] ?? null),
'tax_type' => $this->text($row[3] ?? null),
'status' => $this->text($row[4] ?? null),
'basic_bank' => $this->text($row[5] ?? null),
'basic_account_last4' => $this->text($row[6] ?? null),
'remark' => $this->text($row[9] ?? null),
];
}
return $rows;
}
private function opening(?Worksheet $sheet, string $openingDate): array
{
if (! $sheet) {
throw new RuntimeException('缺少「每日资金流水」工作表。');
}
$rows = [];
foreach ($this->rows($sheet, 6, 8) as $row) {
$short = $this->text($row[2] ?? null);
if ($short === '') {
continue;
}
$rows[] = [
'account_short' => $short,
'opening_date' => $openingDate,
'opening_balance' => $this->number($row[3] ?? null),
'remark' => $this->text($row[7] ?? null),
];
}
return $rows;
}
/** @return array<int,array<int,mixed>> */
private function rows(Worksheet $sheet, int $startRow, int $columnCount): array
{
$lastRow = $sheet->getHighestDataRow();
if ($lastRow < $startRow) {
return [];
}
return $sheet->rangeToArray(
'A' . $startRow . ':' . \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnCount) . $lastRow,
null,
false,
false,
false,
);
}
private function insert($connection, string $table, array $rows, $now): void
{
foreach ($rows as &$row) {
$row['created_at'] = $now;
$row['updated_at'] = $now;
}
unset($row);
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table($table)->insert($chunk);
}
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
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 number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_int($value) || is_float($value)) {
return round((float) $value, 2);
}
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) $value));
if ($text === '' || $text === '-' || $text === '/') {
return 0.0;
}
return is_numeric($text) ? round((float) $text, 2) : 0.0;
}
private function nullableNumber(mixed $value): ?float
{
if ($value === null || trim((string) $value) === '' || trim((string) $value) === '-') {
return null;
}
return $this->number($value);
}
}