CaiWuXiTong/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php
92451 0b69f30714 feat: 补录财务账单店铺余额并标记来源
聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。

PR #552
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-25 17:06:19 +08:00

518 lines
21 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 Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
class JushuitanShopBalanceImporter
{
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $database,
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
$this->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<array<string,mixed>>,1:list<array<string,mixed>>} */
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<string,mixed>|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<array{title:string,fld:string}>,1:list<array<string,mixed>>} */
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<array<string,mixed>> */
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<array<string,mixed>> */
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');
}
}