CaiWuXiTong/modules/EcommerceFinance/Repositories/FinanceBillBalanceRepository.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

290 lines
11 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use RuntimeException;
/**
* 从财务「店铺收款账户账单」读取可用于补充店铺余额的累计账户余额。
*
* 当前只有淘系支付宝和京东钱包账单包含真实账户余额;拼多多、抖音
* 的账单行只有收支发生额,不能据此推导平台账户余额。
*/
class FinanceBillBalanceRepository
{
public const SCOPE_TAOBAO = 'taobao';
public const SCOPE_JD_WALLET = 'jd-wallet';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
public static function supportsScope(string $sourceScope): bool
{
return in_array($sourceScope, [self::SCOPE_TAOBAO, self::SCOPE_JD_WALLET, 'all'], true);
}
/**
* @return list<array{
* source_scope:string,source_platform:string,store_name:string,normalized_store_name:string,
* platform_account_id:?string,balance:float,occurred_at:string,source_row_key:string,source_file_name:string
* }>
*/
public function latestBalances(
string $sourceScope,
string $balanceDate,
array $storeNames = [],
): array
{
if (! self::supportsScope($sourceScope)) {
throw new InvalidArgumentException('当前账单来源没有累计账户余额字段。');
}
$date = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== $balanceDate) {
throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。');
}
$before = $date->addDay()->format('Y-m-d') . ' 00:00:00';
$storeNames = array_values(array_unique(array_filter(array_map(
static fn (mixed $storeName): string => trim((string) $storeName),
$storeNames,
))));
if ($sourceScope === self::SCOPE_TAOBAO) {
return $this->aggregate(
$this->taobaoRows($before, $storeNames),
'alipay_account',
self::SCOPE_TAOBAO,
'财务系统/店铺收款账户账单/淘系支付宝',
);
}
if ($sourceScope === self::SCOPE_JD_WALLET) {
return $this->aggregate(
$this->jdWalletRows($before, $storeNames),
'account_name',
self::SCOPE_JD_WALLET,
'财务系统/店铺收款账户账单/京东钱包',
);
}
return [
...$this->latestBalances(self::SCOPE_TAOBAO, $balanceDate, $storeNames),
...$this->latestBalances(self::SCOPE_JD_WALLET, $balanceDate, $storeNames),
];
}
/** @return Collection<int,object> */
private function taobaoRows(string $before, array $storeNames): Collection
{
if (! Schema::connection($this->connection->getName())->hasTable('taobao_alipay_bill_rows')) {
throw new RuntimeException('finance_taobao_balance_source_not_ready');
}
return $this->latestRows(
'taobao_alipay_bill_rows',
'alipay_account',
'balance_amount',
$before,
$storeNames,
);
}
/** @return Collection<int,object> */
private function jdWalletRows(string $before, array $storeNames): Collection
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('jd_wallet_bill_rows') || ! $schema->hasTable('jd_wallet_bill_periods')) {
throw new RuntimeException('finance_jd_wallet_balance_source_not_ready');
}
$generations = $this->connection->table('jd_wallet_bill_periods')
->whereNotNull('active_generation')
->pluck('active_generation')
->filter()
->values()
->all();
if ($generations === []) {
throw new RuntimeException('finance_jd_wallet_balance_source_not_ready');
}
return $this->latestRows(
'jd_wallet_bill_rows',
'account_name',
'balance',
$before,
$storeNames,
$generations,
);
}
/**
* 先按店铺和账户截取最新时间,再取同一时间的最大记录编码。
* 避免在千万级账单表上对整个历史窗口做 ROW_NUMBER()。
*
* @param list<string> $storeNames
* @param list<string>|null $generations
* @return Collection<int,object>
*/
private function latestRows(
string $table,
string $accountField,
string $balanceField,
string $before,
array $storeNames,
?array $generations = null,
): Collection {
$latestGroups = $this->connection->table($table)
->when(
$generations !== null,
static fn ($query) => $query->whereIn('generation', $generations),
)
->whereNotNull('store_name')
->where('store_name', '<>', '')
->whereNotNull($balanceField)
->where('occurred_at', '<', $before)
->when(
$storeNames !== [],
static fn ($query) => $query->whereIn('store_name', $storeNames),
)
->select(['store_name'])
->selectRaw("COALESCE({$accountField}, '') AS account_key")
->selectRaw('MAX(occurred_at) AS latest_at')
->groupByRaw("store_name, COALESCE({$accountField}, '')");
$tableAlias = $this->connection->getTablePrefix() . 'bill_rows';
$ranked = $this->connection->table($table . ' as bill_rows')
->joinSub(
$latestGroups,
'latest_groups',
function ($join) use ($accountField, $tableAlias): void {
$join
->on('bill_rows.store_name', '=', 'latest_groups.store_name')
->on(
$this->connection->raw("COALESCE({$tableAlias}.{$accountField}, '')"),
'=',
'latest_groups.account_key',
);
},
)
->when(
$generations !== null,
static fn ($query) => $query->whereIn('bill_rows.generation', $generations),
)
->whereColumn('bill_rows.occurred_at', '=', 'latest_groups.latest_at')
->select([
'bill_rows.record_code',
'bill_rows.store_name',
"bill_rows.{$accountField}",
'bill_rows.occurred_at',
"bill_rows.{$balanceField}",
])
->selectRaw(
"ROW_NUMBER() OVER ("
. "PARTITION BY {$tableAlias}.store_name, COALESCE({$tableAlias}.{$accountField}, '') "
. "ORDER BY LENGTH({$tableAlias}.record_code) DESC, {$tableAlias}.record_code DESC"
. ') AS balance_rank',
);
return $this->connection->query()
->fromSub($ranked, 'ranked_balances')
->where('balance_rank', 1)
->get([
'record_code',
'store_name',
$accountField,
'occurred_at',
$balanceField,
]);
}
/**
* 同一店铺存在多个账户时,余额按店铺汇总;与页面「账户余额合计」的
* 展示口径一致。
*
* @param Collection<int,object> $rows
* @return list<array{
* source_scope:string,source_platform:string,store_name:string,normalized_store_name:string,
* platform_account_id:?string,balance:float,occurred_at:string,source_row_key:string,source_file_name:string
* }>
*/
private function aggregate(
Collection $rows,
string $accountField,
string $sourceScope,
string $sourceFileName,
): array {
$aggregated = [];
foreach ($rows as $row) {
$storeName = trim((string) ($row->store_name ?? ''));
$sourcePlatform = JushuitanShopBalanceRepository::sourcePlatform($storeName);
$platformKey = JushuitanShopBalanceRepository::platformKey((string) $sourcePlatform);
$normalizedStoreName = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
if ($platformKey === null || $storeName === '' || $normalizedStoreName === '') {
continue;
}
$key = $platformKey . "\0" . $normalizedStoreName;
if (! isset($aggregated[$key])) {
$aggregated[$key] = [
'source_platform' => (string) $sourcePlatform,
'store_name' => $storeName,
'normalized_store_name' => $normalizedStoreName,
'balance' => 0.0,
'occurred_at' => '',
'accounts' => [],
];
}
$account = trim((string) ($row->{$accountField} ?? ''));
if ($account !== '') {
$aggregated[$key]['accounts'][$account] = true;
}
$aggregated[$key]['balance'] += (float) $row->{$sourceScope === self::SCOPE_TAOBAO ? 'balance_amount' : 'balance'};
if ((string) $row->occurred_at > $aggregated[$key]['occurred_at']) {
$aggregated[$key]['occurred_at'] = (string) $row->occurred_at;
}
}
$result = [];
foreach ($aggregated as $key => $item) {
$accounts = array_keys($item['accounts']);
sort($accounts, SORT_NATURAL);
$result[] = [
'source_scope' => $sourceScope,
'source_platform' => $item['source_platform'],
'store_name' => $item['store_name'],
'normalized_store_name' => $item['normalized_store_name'],
'platform_account_id' => $accounts === [] ? null : mb_substr(implode(',', $accounts), 0, 191),
'balance' => round((float) $item['balance'], 6),
'occurred_at' => $item['occurred_at'],
'source_row_key' => hash('sha256', 'finance' . "\0" . $key),
'source_file_name' => $sourceFileName,
];
}
usort(
$result,
static fn (array $left, array $right): int => strcmp(
$left['source_scope'] . "\0" . $left['normalized_store_name'],
$right['source_scope'] . "\0" . $right['normalized_store_name'],
),
);
return $result;
}
}