CaiWuXiTong/modules/EcommerceFinance/Services/FinanceBillShopBalanceFallback.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

281 lines
10 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\FinanceBillBalanceRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
/**
* 聚水潭余额缺失时,用财务系统店铺收款账户账单中的累计余额补位。
*/
class FinanceBillShopBalanceFallback
{
private const SCOPE_PLATFORM_KEYS = [
FinanceBillBalanceRepository::SCOPE_TAOBAO => ['tmall', 'taobao', '1688'],
FinanceBillBalanceRepository::SCOPE_JD_WALLET => ['jd', 'jingxi'],
'pinduoduo' => ['pinduoduo'],
'douyin' => ['douyin'],
];
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $database,
private readonly JushuitanShopBalanceRepository $jushuitanBalances,
private readonly FinanceBillBalanceRepository $financeBalances,
) {
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{
* source_scope:string,supported:bool,reason:?string,missing:int,resolved:int,
* unresolved:int,filled:int,dry_run:bool
* }
*/
public function fillMissing(string $balanceDate, string $sourceScope, bool $dryRun = false): array
{
$date = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== $balanceDate) {
throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。');
}
$sourceScope = trim($sourceScope);
$platformKeys = $this->platformKeysForScope($sourceScope);
if ($platformKeys === []) {
return [
'source_scope' => $sourceScope,
'supported' => false,
'reason' => 'unknown_balance_scope',
'missing' => 0,
'resolved' => 0,
'unresolved' => 0,
'filled' => 0,
'dry_run' => $dryRun,
];
}
$accounts = StoreReceivingAccount::withoutGlobalScopes()
->whereNull('deleted_at')
->get()
->filter(static function (StoreReceivingAccount $account) use ($platformKeys): bool {
$platformKey = JushuitanShopBalanceRepository::platformKey(
(string) $account->store_platform,
);
return $platformKey !== null && in_array($platformKey, $platformKeys, true);
})
->values();
if ($accounts->isEmpty()) {
return $this->result($sourceScope, true, null, 0, 0, $dryRun);
}
$matches = $this->jushuitanBalances->matchesForStoresOnDates(
$accounts,
[$balanceDate],
JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN,
);
$missingAccounts = $accounts->filter(
static function (StoreReceivingAccount $account) use ($matches, $balanceDate): bool {
$status = $matches[(int) $account->id][$balanceDate]['status'] ?? 'not_ready';
return in_array($status, ['unmatched', 'not_ready'], true);
},
)->values();
$providerScopes = $this->providerScopes($sourceScope);
if ($missingAccounts->isEmpty()) {
if (! $dryRun && $providerScopes !== []) {
$this->replaceFinanceRows($balanceDate, $sourceScope, []);
}
return $this->result(
$sourceScope,
$providerScopes !== [],
$providerScopes === [] ? 'finance_bill_has_no_account_balance' : null,
0,
0,
$dryRun,
);
}
if ($providerScopes === []) {
return $this->result(
$sourceScope,
false,
'finance_bill_has_no_account_balance',
$missingAccounts->count(),
0,
$dryRun,
);
}
$storeNames = $missingAccounts
->flatMap(static fn (StoreReceivingAccount $account): array => [
(string) ($account->shop_name ?? ''),
(string) ($account->erp_store_name ?? ''),
])
->map(static fn (string $storeName): string => trim($storeName))
->filter()
->unique()
->values()
->all();
$financeRows = [];
foreach ($providerScopes as $providerScope) {
$financeRows = [
...$financeRows,
...$this->financeBalances->latestBalances($providerScope, $balanceDate, $storeNames),
];
}
$financeByStore = [];
foreach ($financeRows as $row) {
$platformKey = JushuitanShopBalanceRepository::platformKey(
(string) ($row['source_platform'] ?? ''),
);
if ($platformKey === null) {
continue;
}
$financeByStore[$platformKey . "\0" . $row['normalized_store_name']] = $row;
}
$now = now();
$rows = [];
$resolved = 0;
foreach ($missingAccounts as $account) {
$platformKey = JushuitanShopBalanceRepository::platformKey(
(string) $account->store_platform,
);
if ($platformKey === null) {
continue;
}
$names = array_values(array_unique(array_filter([
JushuitanShopBalanceRepository::normalizeStoreName((string) ($account->shop_name ?? '')),
JushuitanShopBalanceRepository::normalizeStoreName((string) ($account->erp_store_name ?? '')),
])));
$candidates = [];
foreach ($names as $normalizedStoreName) {
$key = $platformKey . "\0" . $normalizedStoreName;
if (isset($financeByStore[$key])) {
$candidates[$key] = $financeByStore[$key];
}
}
if (count($candidates) !== 1) {
continue;
}
$resolved++;
$candidateKey = array_key_first($candidates);
if (isset($rows[$candidateKey])) {
continue;
}
$candidate = $candidates[$candidateKey];
$rows[$candidateKey] = [
'balance_date' => $balanceDate,
'source_scope' => $sourceScope,
'data_source' => JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE,
'source_platform' => $candidate['source_platform'],
'store_name' => $candidate['store_name'],
'normalized_store_name' => $candidate['normalized_store_name'],
'platform_account_id' => $candidate['platform_account_id'],
'balance' => round((float) $candidate['balance'], 6),
'source_row_key' => hash('sha256', 'finance' . "\0" . $sourceScope . "\0" . $candidateKey),
'source_file_name' => mb_substr((string) $candidate['source_file_name'], 0, 255),
'source_sha256' => hash(
'sha256',
implode("\0", [$balanceDate, $sourceScope, $candidateKey, (string) $candidate['occurred_at']]),
),
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! $dryRun) {
$rows = array_values($rows);
$this->replaceFinanceRows($balanceDate, $sourceScope, $rows);
}
return $this->result(
$sourceScope,
true,
null,
$missingAccounts->count(),
$resolved,
$dryRun,
count($rows),
);
}
/** @return list<string> */
private function platformKeysForScope(string $sourceScope): array
{
if ($sourceScope === 'all') {
return array_values(array_unique(array_merge(...array_values(self::SCOPE_PLATFORM_KEYS))));
}
return self::SCOPE_PLATFORM_KEYS[$sourceScope] ?? [];
}
/** @return list<string> */
private function providerScopes(string $sourceScope): array
{
return match ($sourceScope) {
'all' => [
FinanceBillBalanceRepository::SCOPE_TAOBAO,
FinanceBillBalanceRepository::SCOPE_JD_WALLET,
],
FinanceBillBalanceRepository::SCOPE_TAOBAO,
FinanceBillBalanceRepository::SCOPE_JD_WALLET => [$sourceScope],
default => [],
};
}
/** @param list<array<string,mixed>> $rows */
private function replaceFinanceRows(string $balanceDate, string $sourceScope, array $rows): void
{
$this->connection->transaction(function () use ($balanceDate, $sourceScope, $rows): void {
$this->connection->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', $balanceDate)
->where('source_scope', $sourceScope)
->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE)
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table(JushuitanShopBalanceRepository::TABLE)->insert($chunk);
}
});
}
/** @return array{source_scope:string,supported:bool,reason:?string,missing:int,resolved:int,unresolved:int,filled:int,dry_run:bool} */
private function result(
string $sourceScope,
bool $supported,
?string $reason,
int $missing,
int $resolved,
bool $dryRun,
?int $filled = null,
): array {
return [
'source_scope' => $sourceScope,
'supported' => $supported,
'reason' => $reason,
'missing' => $missing,
'resolved' => $resolved,
'unresolved' => max(0, $missing - $resolved),
'filled' => $filled ?? 0,
'dry_run' => $dryRun,
];
}
}