聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。 PR #552 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
518 lines
21 KiB
PHP
518 lines
21 KiB
PHP
<?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');
|
||
}
|
||
}
|