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