317 lines
12 KiB
PHP
317 lines
12 KiB
PHP
<?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);
|
||
}
|
||
}
|