connection = $database->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** @return array{balance_date:string,total:int,replaced:int,dry_run:bool} */ public function import( string $path, ?string $balanceDate = null, string $sourceScope = 'all', bool $dryRun = false, ): array { $realPath = realpath($path); if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { throw new InvalidArgumentException('聚水潭店铺余额文件不存在或不可读取。'); } $sourceScope = trim($sourceScope); if ($sourceScope === '' || mb_strlen($sourceScope) > 64) { throw new InvalidArgumentException('来源范围不能为空且不能超过 64 个字符。'); } $requestedDate = $this->date($balanceDate); [$headers, $dataRows] = match (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION))) { 'json' => $this->jsonRows($realPath), 'xlsx', 'xls' => $this->workbookRows($realPath), default => throw new InvalidArgumentException('店铺余额仅支持聚水潭接口 JSON 或官方 XLSX/XLS 导出文件。'), }; $walletFields = $this->jdWalletFields($headers); if ($walletFields !== null) { if ($requestedDate === null) { throw new InvalidArgumentException('京东钱包余额缺少截止日期,请使用 --balance-date 指定导出截止日期。'); } [$storeField, $accountField, $settledAtField, $recordCodeField, $balanceField] = $walletFields; $selectedDate = $requestedDate; $rows = $this->normalizeJdWalletRows( $dataRows, $selectedDate, $storeField, $accountField, $settledAtField, $recordCodeField, $balanceField, ); } else { [$selectedDate, $storeField, $accountField, $balanceField, $platformField] = $this->fields($headers, $requestedDate); $rows = $this->normalizeRows( $dataRows, $selectedDate, $storeField, $accountField, $balanceField, $platformField, ); } if ($rows === []) { throw new InvalidArgumentException('聚水潭店铺余额文件中没有可导入的数据行,已保留原有快照。'); } if (! Schema::connection($this->connection->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) { throw new RuntimeException('聚水潭店铺余额镜像表尚未创建,请先执行数据库迁移。'); } $existing = $this->connection->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', $selectedDate) ->where('source_scope', $sourceScope) ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN) ->count(); if (! $dryRun) { $now = now(); $sourceName = mb_substr(basename($realPath), 0, 255); $sourceSha256 = hash_file('sha256', $realPath); $this->connection->transaction(function () use ( $rows, $selectedDate, $sourceScope, $sourceName, $sourceSha256, $now ): void { $this->connection->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', $selectedDate) ->where('source_scope', $sourceScope) ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN) ->delete(); foreach (array_chunk($rows, 500) as $chunk) { $this->connection->table(JushuitanShopBalanceRepository::TABLE)->insert(array_map( static fn (array $row): array => $row + [ 'balance_date' => $selectedDate, 'source_scope' => $sourceScope, 'data_source' => JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, 'source_file_name' => $sourceName, 'source_sha256' => $sourceSha256, 'imported_at' => $now, 'created_at' => $now, 'updated_at' => $now, ], $chunk, )); } }); } return [ 'balance_date' => $selectedDate, 'total' => count($rows), 'replaced' => $existing, 'dry_run' => $dryRun, ]; } /** @return array{0:list>,1:list>} */ private function jsonRows(string $path): array { $payload = json_decode((string) file_get_contents($path), true); if (! is_array($payload)) { throw new InvalidArgumentException('聚水潭接口 JSON 格式无效。'); } $table = $this->findTablePayload($payload); if ($table === null) { throw new InvalidArgumentException('接口响应中缺少 fldHeads 或 dataList。'); } if (array_key_exists('isSuccess', $table) && ! $table['isSuccess']) { throw new InvalidArgumentException('聚水潭接口返回失败:' . trim((string) ($table['msg'] ?? $table['message'] ?? '未知错误'))); } return [array_values($table['fldHeads']), array_values($table['dataList'])]; } /** @return array|null */ private function findTablePayload(array $payload): ?array { if (isset($payload['fldHeads'], $payload['dataList']) && is_array($payload['fldHeads']) && is_array($payload['dataList'])) { return $payload; } foreach ($payload as $value) { if (is_array($value) && ($found = $this->findTablePayload($value)) !== null) { return $found; } } return null; } /** @return array{0:list,1:list>} */ private function workbookRows(string $path): array { $spreadsheet = IOFactory::load($path); $worksheet = $spreadsheet->getActiveSheet(); $matrix = $worksheet->toArray(null, true, true, true); $spreadsheet->disconnectWorksheets(); $headerNumber = null; foreach (array_slice($matrix, 0, 20, true) as $rowNumber => $row) { $values = array_map(static fn (mixed $value): string => trim((string) $value), $row); $hasBalanceColumn = in_array('平台实际期末余额', $values, true) || in_array('账户余额', $values, true) || count(array_filter($values, fn (string $value): bool => $this->dateFromHeader($value, null) !== null)) > 0; if (in_array('店铺', $values, true) && $hasBalanceColumn) { $headerNumber = $rowNumber; break; } } if ($headerNumber === null) { throw new InvalidArgumentException('聚水潭导出文件中未找到“店铺”和余额列。'); } $headers = []; foreach ($matrix[$headerNumber] as $column => $title) { if (trim((string) $title) !== '') { $headers[] = ['title' => trim((string) $title), 'fld' => (string) $column]; } } return [$headers, array_values(array_filter( array_slice($matrix, (int) $headerNumber), static fn (array $row): bool => count(array_filter($row, static fn (mixed $value): bool => trim((string) $value) !== '')) > 0, ))]; } /** @return array{0:string,1:?string,2:string,3:string,4:string}|null */ private function jdWalletFields(array $headers): ?array { $fields = []; foreach ($headers as $header) { $title = trim((string) ($header['title'] ?? '')); $field = trim((string) ($header['fld'] ?? '')); if ($title !== '' && $field !== '') { $fields[$title] = $field; } } if (! isset($fields['费用结算时间']) && ! isset($fields['记录编码']) && ! isset($fields['账户余额'])) { return null; } $missing = array_values(array_filter( ['店铺', '费用结算时间', '记录编码', '账户余额'], static fn (string $title): bool => ! isset($fields[$title]), )); if ($missing !== []) { throw new InvalidArgumentException('京东钱包余额数据缺少“' . implode('”“', $missing) . '”列。'); } return [ $fields['店铺'], $fields['账户名称'] ?? null, $fields['费用结算时间'], $fields['记录编码'], $fields['账户余额'], ]; } /** @return array{0:string,1:string,2:?string,3:string,4:?string} */ private function fields(array $headers, ?string $requestedDate): array { $storeField = null; $accountField = null; $platformField = null; $dateFields = []; $actualBalanceField = null; foreach ($headers as $header) { $title = trim((string) ($header['title'] ?? '')); $field = trim((string) ($header['fld'] ?? '')); if ($field === '') { continue; } if (in_array($title, ['店铺', '店铺名称'], true) || $field === 'shop_name') { $storeField = $field; } if (in_array($title, ['支付宝账号', '平台账号'], true) || $field === 'platform_shop_id') { $accountField = $field; } if ($title === '平台' || $field === 'platform') { $platformField = $field; } if ($title === '平台实际期末余额') { $actualBalanceField = $field; } $date = $this->dateFromHeader($title, $requestedDate) ?? $this->dateFromHeader($field, $requestedDate); if ($date !== null) { $dateFields[$field] = $date; } } if ($storeField === null) { throw new InvalidArgumentException('聚水潭店铺余额数据缺少“店铺”列。'); } if ($requestedDate !== null) { $balanceField = array_search($requestedDate, $dateFields, true); if ($balanceField === false && $actualBalanceField !== null) { $balanceField = $actualBalanceField; } if ($balanceField === false) { throw new InvalidArgumentException("店铺余额数据中没有 {$requestedDate} 日期列。"); } return [$requestedDate, $storeField, $accountField, (string) $balanceField, $platformField]; } $uniqueDates = array_values(array_unique($dateFields)); if ($uniqueDates === [] && $actualBalanceField !== null) { throw new InvalidArgumentException('平台资金账户余额缺少日期,请使用 --balance-date 指定导出日期。'); } if (count($uniqueDates) !== 1) { throw new InvalidArgumentException('店铺余额包含多个日期,请使用 --balance-date 指定要导入的日期。'); } $selectedDate = $uniqueDates[0]; $balanceField = array_search($selectedDate, $dateFields, true); return [$selectedDate, $storeField, $accountField, (string) $balanceField, $platformField]; } /** @return list> */ private function normalizeRows( array $dataRows, string $balanceDate, string $storeField, ?string $accountField, string $balanceField, ?string $platformField, ): array { $rows = []; foreach ($dataRows as $row) { if (! is_array($row)) { continue; } $storeName = trim((string) ($row[$storeField] ?? '')); if ($storeName === '' || $storeName === '总计') { continue; } if (mb_strlen($storeName) > 255) { throw new InvalidArgumentException('聚水潭店铺名称超过 255 个字符:' . mb_substr($storeName, 0, 40)); } $balance = $this->amount($row[$balanceField] ?? null, $storeName, $balanceDate); $accountId = $accountField === null ? '' : trim((string) ($row[$accountField] ?? '')); $sourcePlatform = $platformField === null ? '' : trim((string) ($row[$platformField] ?? '')); if ($sourcePlatform === '') { $sourcePlatform = JushuitanShopBalanceRepository::sourcePlatform($storeName); } $normalizedName = JushuitanShopBalanceRepository::normalizeStoreName($storeName); $rowKey = hash('sha256', implode("\0", [$sourcePlatform, $normalizedName, $accountId])); if (isset($rows[$rowKey]) && $rows[$rowKey]['balance'] !== $balance) { throw new InvalidArgumentException("店铺 {$storeName} 在 {$balanceDate} 存在冲突余额。"); } $rows[$rowKey] = [ 'source_platform' => $sourcePlatform, 'store_name' => $storeName, 'normalized_store_name' => $normalizedName, 'platform_account_id' => $accountId === '' ? null : mb_substr($accountId, 0, 191), 'balance' => $balance, 'source_row_key' => $rowKey, ]; } return array_values($rows); } /** @return list> */ private function normalizeJdWalletRows( array $dataRows, string $balanceDate, string $storeField, ?string $accountField, string $settledAtField, string $recordCodeField, string $balanceField, ): array { $cutoff = CarbonImmutable::createFromFormat( '!Y-m-d', $balanceDate, (string) config('app.timezone', 'Asia/Shanghai'), )->endOfDay(); $latestByStore = []; foreach ($dataRows as $row) { if (! is_array($row)) { continue; } $storeName = trim((string) ($row[$storeField] ?? '')); if ($storeName === '' || $storeName === '总计') { continue; } if (mb_strlen($storeName) > 255) { throw new InvalidArgumentException('聚水潭店铺名称超过 255 个字符:' . mb_substr($storeName, 0, 40)); } $settledAt = $this->settledAt($row[$settledAtField] ?? null, $storeName); if ($settledAt->isAfter($cutoff)) { continue; } $recordCode = trim((string) ($row[$recordCodeField] ?? '')); if (preg_match('/^\d+$/', $recordCode) !== 1) { throw new InvalidArgumentException("店铺 {$storeName} 的记录编码不是有效非负整数。"); } $sourcePlatform = JushuitanShopBalanceRepository::sourcePlatform($storeName) ?? ''; $normalizedName = JushuitanShopBalanceRepository::normalizeStoreName($storeName); $storeKey = hash('sha256', implode("\0", [$sourcePlatform, $normalizedName])); $candidate = [ 'settled_at' => $settledAt, 'record_code' => $recordCode, 'balance' => $this->amount($row[$balanceField] ?? null, $storeName, $balanceDate), 'account_id' => $accountField === null ? '' : trim((string) ($row[$accountField] ?? '')), 'source_platform' => $sourcePlatform, 'store_name' => $storeName, 'normalized_store_name' => $normalizedName, ]; $current = $latestByStore[$storeKey] ?? null; if ($current !== null) { $comparison = $settledAt->getTimestamp() <=> $current['settled_at']->getTimestamp(); if ($comparison === 0) { $comparison = $this->compareNumericStrings($recordCode, $current['record_code']); if ($comparison === 0 && $candidate['balance'] !== $current['balance']) { throw new InvalidArgumentException("店铺 {$storeName} 的同一京东钱包记录存在冲突余额。"); } } if ($comparison <= 0) { continue; } } $latestByStore[$storeKey] = $candidate; } return array_values(array_map(static function (array $row): array { $accountId = $row['account_id']; $rowKey = hash('sha256', implode("\0", [ $row['source_platform'], $row['normalized_store_name'], $accountId, ])); return [ 'source_platform' => $row['source_platform'], 'store_name' => $row['store_name'], 'normalized_store_name' => $row['normalized_store_name'], 'platform_account_id' => $accountId === '' ? null : mb_substr($accountId, 0, 191), 'balance' => $row['balance'], 'source_row_key' => $rowKey, ]; }, $latestByStore)); } private function settledAt(mixed $value, string $storeName): CarbonImmutable { $raw = trim((string) $value); try { $settledAt = CarbonImmutable::createFromFormat( '!Y-m-d H:i:s', $raw, (string) config('app.timezone', 'Asia/Shanghai'), ); } catch (\Throwable) { throw new InvalidArgumentException("店铺 {$storeName} 的费用结算时间格式无效。"); } if (! $settledAt instanceof CarbonImmutable || $settledAt->format('Y-m-d H:i:s') !== $raw) { throw new InvalidArgumentException("店铺 {$storeName} 的费用结算时间格式无效。"); } return $settledAt; } private function compareNumericStrings(string $left, string $right): int { $left = ltrim($left, '0') ?: '0'; $right = ltrim($right, '0') ?: '0'; return strlen($left) <=> strlen($right) ?: strcmp($left, $right); } private function amount(mixed $value, string $storeName, string $date): float { $normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value)); if ($normalized === '' || ! is_numeric($normalized)) { throw new InvalidArgumentException("店铺 {$storeName} 的 {$date} 余额不是有效金额。"); } return round((float) $normalized, 6); } private function date(?string $value): ?string { if ($value === null || trim($value) === '') { return null; } try { $date = CarbonImmutable::createFromFormat('!Y-m-d', trim($value)); } catch (\Throwable) { throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。'); } if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== trim($value)) { throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。'); } return $date->format('Y-m-d'); } private function dateFromHeader(string $value, ?string $requestedDate): ?string { $value = trim($value); if (preg_match('/(20\d{2})[-_\/]?(\d{2})[-_\/]?(\d{2})/', $value, $matches) === 1) { return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]); } if (preg_match('/^(\d{1,2})[-\/](\d{1,2})$/', $value, $matches) !== 1) { return null; } $year = $requestedDate === null ? (int) now()->format('Y') : (int) substr($requestedDate, 0, 4); $date = CarbonImmutable::create($year, (int) $matches[1], (int) $matches[2])->startOfDay(); if ($requestedDate === null && $date->isAfter(now()->addDay()->endOfDay())) { $date = $date->subYear(); } return $date->format('Y-m-d'); } }