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> */ 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); } }