CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerShopDataSynchronizer.php
92451 1cb04adc7b feat: 店铺台账接入每日余额、预留标准与预警
完成店铺台账每日余额来源、日期筛选、预留标准和连续3天预警。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-27 18:13:00 +08:00

603 lines
24 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use App\Business\WithdrawalBalanceAlertSynchronizer;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
class FinanceLedgerShopDataSynchronizer
{
public const WITHDRAWAL_SOURCE = FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE;
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly JushuitanShopBalanceRepository $shopBalances,
private readonly BillingDetailsPageData $billingDetails,
private readonly StoreReceivingBillBalanceSnapshotSynchronizer $balanceSnapshots,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{
* balance:array{date:?string,source:int,receiving_bill:int,jushuitan:int,added:int,rebuilt:int,matched:int,amount:float,alerts:array{created:int,updated:int}},
* withdrawals:array{from:string,to:string,processed:int,written:int,upstream_duplicates:int,unmatched:int,account_missing:int,income:float,expense:float},
* recalculated:?array
* }
*/
public function sync(?string $from = null, ?string $to = null, bool $recalculate = true, bool $rebuild = false): array
{
$previousNight = now()->startOfDay()->subDay()->toDateString();
$to = $this->validDate($to) ?? $previousNight;
if ($to > $previousNight) {
$to = $previousNight;
}
$from = $this->validDate($from)
?? (string) config('finance_ledger.opening_date', '2026-10-17');
if ($from > $to) {
$from = $to;
}
$balance = $this->syncShopBalances($to, $rebuild);
$withdrawals = $this->syncWithdrawals($from, $to);
$recalculated = $recalculate && ($balance['matched'] > 0 || $withdrawals['written'] > 0)
? app(FinanceLedgerCalculator::class)->recalculate()
: null;
return [
'balance' => $balance,
'withdrawals' => $withdrawals,
'recalculated' => $recalculated,
];
}
/**
* @return array{date:?string,source:int,added:int,rebuilt:int,matched:int,amount:float}
*/
public function syncShopBalances(?string $through = null, bool $rebuild = false): array
{
$through = $this->validDate($through) ?? now()->startOfDay()->subDay()->toDateString();
$snapshot = $this->balanceSnapshots->sync($through);
$balanceDate = $snapshot['total'] > 0
? (string) $snapshot['date']
: $this->shopBalances->latestBalanceDate(
CarbonImmutable::parse($through),
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
);
if ($balanceDate === null) {
return [
'date' => null,
'source' => 0,
'receiving_bill' => 0,
'jushuitan' => 0,
'added' => 0,
'rebuilt' => 0,
'matched' => 0,
'amount' => 0.0,
'alerts' => ['created' => 0, 'updated' => 0],
];
}
$rebuilt = $rebuild ? $this->rebuildShopLedger($balanceDate) : 0;
$shops = $this->connection->table('fin_shops')
->orderBy('id')
->get(['id', 'shop_name', 'platform', 'account_short']);
$added = $rebuilt > 0 ? 0 : $this->addConfirmedMissingShops($balanceDate, $shops);
if ($added > 0) {
$shops = $this->connection->table('fin_shops')
->orderBy('id')
->get(['id', 'shop_name', 'platform', 'account_short']);
}
$matches = $this->shopBalances->matchesForStores(
$shops,
$balanceDate,
null,
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
);
$updates = [];
foreach ($matches as $id => $match) {
if (($match['status'] ?? '') === 'matched' && $match['balance'] !== null) {
$updates[(int) $id] = round((float) $match['balance'], 2);
}
}
if ($updates !== []) {
$hasBalanceDate = $this->connection->getSchemaBuilder()->hasColumn('fin_shops', 'shop_balance_date');
foreach (array_chunk($updates, 400, true) as $chunk) {
$case = [];
foreach ($chunk as $id => $amount) {
$case[] = 'WHEN ' . $id . ' THEN ' . number_format($amount, 2, '.', '');
}
$values = [
'shop_balance' => $this->connection->raw(
'CASE `id` ' . implode(' ', $case) . ' ELSE `shop_balance` END',
),
'updated_at' => now(),
];
if ($hasBalanceDate) {
$values['shop_balance_date'] = $balanceDate;
}
$this->connection->table('fin_shops')
->whereIn('id', array_keys($chunk))
->update($values);
}
}
$alerts = ['created' => 0, 'updated' => 0];
if ($updates !== []) {
$companyIds = StoreReceivingAccount::withoutGlobalScopes()
->whereNull('deleted_at')
->distinct()
->pluck('company_id')
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
$alertSynchronizer = app(WithdrawalBalanceAlertSynchronizer::class);
foreach ($companyIds as $companyId) {
$result = $alertSynchronizer->syncForDate(
$companyId,
$balanceDate,
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
);
$alerts['created'] += $result['created'];
$alerts['updated'] += $result['updated'];
}
}
return [
'date' => $balanceDate,
'source' => $this->shopBalances->countForDate(
$balanceDate,
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
),
'receiving_bill' => $snapshot['receiving_bill'],
'jushuitan' => $snapshot['jushuitan'],
'added' => $added,
'rebuilt' => $rebuilt,
'matched' => count($updates),
'amount' => round(array_sum($updates), 2),
'alerts' => $alerts,
];
}
private function rebuildShopLedger(string $balanceDate): int
{
$sourceRows = $this->connection->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', $balanceDate)
->where('source_scope', StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE)
->orderByRaw("CASE WHEN data_source = 'jushuitan' THEN 0 ELSE 1 END")
->orderBy('id')
->get(['source_platform', 'store_name']);
if ($sourceRows->isEmpty()) {
return 0;
}
$receivingByNormalized = [];
foreach (StoreReceivingAccount::withoutGlobalScopes()->whereNull('deleted_at')->get() as $account) {
$name = trim((string) ($account->erp_store_name ?? ''));
if ($name !== '') {
$receivingByNormalized[JushuitanShopBalanceRepository::normalizeStoreName($name)][] = $account;
}
}
$accountShortByShop = $this->connection->table('fin_account_map')
->where('source', 'shop')
->whereNotNull('account_short')
->where('account_short', '<>', '')
->pluck('account_short', 'source_value');
$now = now();
$seen = [];
$rows = [];
foreach ($sourceRows as $source) {
$name = trim((string) ($source->store_name ?? ''));
if ($name === '' || isset($seen[$name])) {
continue;
}
$seen[$name] = true;
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($name);
$receivingMatches = $receivingByNormalized[$normalized] ?? [];
$receiving = count($receivingMatches) === 1 ? $receivingMatches[0] : null;
$accountShort = trim((string) ($accountShortByShop[$name] ?? ''));
$bankLastFour = $receiving === null ? '' : trim((string) ($receiving->bank_card_last_four ?? ''));
$payout = $receiving === null ? '' : trim((string) ($receiving->withdrawal_account_name ?? ''));
if ($payout !== '' && $bankLastFour !== '') {
$payout .= ' ' . $bankLastFour;
}
$row = [
'shop_name' => mb_substr($name, 0, 200),
'platform' => mb_substr($this->financialPlatform((string) ($source->source_platform ?? ''), $name), 0, 50),
'company_name' => $receiving === null
? null
: (mb_substr(trim((string) ($receiving->store_entity_name ?? '')), 0, 100) ?: null),
'account_short' => $accountShort === '' ? null : mb_substr($accountShort, 0, 100),
'status' => $receiving === null ? '待确认' : $this->financialShopStatus($receiving->store_status),
'payout_account' => $payout === '' ? null : mb_substr($payout, 0, 100),
'shop_balance' => 0,
'remark' => $receiving === null
? '由聚水潭店铺余额快照重新生成,待确认回款账户'
: '由店铺主数据与最新店铺余额快照重新生成',
'created_at' => $now,
'updated_at' => $now,
];
if ($this->connection->getSchemaBuilder()->hasColumn('fin_shops', 'shop_balance_date')) {
$row['shop_balance_date'] = null;
}
$rows[] = $row;
}
if ($rows === []) {
return 0;
}
return $this->connection->transaction(function () use ($rows): int {
$this->connection->table('fin_shops')->delete();
foreach (array_chunk($rows, 200) as $chunk) {
$this->connection->table('fin_shops')->insert($chunk);
}
return count($rows);
});
}
private function addConfirmedMissingShops(string $balanceDate, $existingShops): int
{
$sourceRows = $this->connection->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', $balanceDate)
->where('source_scope', StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE)
->get(['source_platform', 'store_name']);
$existingExact = [];
$existingNormalized = [];
foreach ($existingShops as $shop) {
$name = trim((string) ($shop->shop_name ?? ''));
if ($name === '') {
continue;
}
$existingExact[$name] = true;
$existingNormalized[JushuitanShopBalanceRepository::normalizeStoreName($name)] = true;
}
$receivingByName = [];
foreach (StoreReceivingAccount::withoutGlobalScopes()->get() as $account) {
$name = trim((string) ($account->erp_store_name ?? ''));
if ($name === '') {
continue;
}
$receivingByName[JushuitanShopBalanceRepository::normalizeStoreName($name)][] = $account;
}
$seen = [];
$insert = [];
$now = now();
foreach ($sourceRows as $source) {
$name = trim((string) ($source->store_name ?? ''));
if ($name === '' || isset($seen[$name]) || isset($existingExact[$name])) {
continue;
}
$seen[$name] = true;
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($name);
if (isset($existingNormalized[$normalized])) {
continue;
}
$receivingMatches = $receivingByName[$normalized] ?? [];
$receiving = count($receivingMatches) === 1 ? $receivingMatches[0] : null;
$bankLastFour = $receiving === null ? '' : trim((string) ($receiving->bank_card_last_four ?? ''));
$payout = $receiving === null ? '' : trim((string) ($receiving->withdrawal_account_name ?? ''));
if ($payout !== '' && $bankLastFour !== '') {
$payout .= ' ' . $bankLastFour;
}
$insert[] = [
'shop_name' => mb_substr($name, 0, 200),
'platform' => $this->financialPlatform((string) ($source->source_platform ?? ''), $name),
'company_name' => $receiving === null
? null
: (trim((string) ($receiving->store_entity_name ?? '')) ?: null),
'account_short' => null,
'status' => $receiving === null
? '待确认'
: (trim((string) ($receiving->store_status ?? '')) ?: '在营'),
'payout_account' => $payout !== '' ? mb_substr($payout, 0, 100) : null,
'shop_balance' => 0,
'shop_balance_date' => null,
'remark' => $receiving === null
? '由聚水潭店铺余额快照补充,待确认回款账户'
: '由店铺收款账户和余额快照补充,待确认回款账户',
'created_at' => $now,
'updated_at' => $now,
];
}
if ($insert === []) {
return 0;
}
$inserted = 0;
foreach (array_chunk($insert, 200) as $chunk) {
$inserted += $this->connection->table('fin_shops')->insertOrIgnore($chunk);
}
return $inserted;
}
/**
* @return array{from:string,to:string,processed:int,written:int,upstream_duplicates:int,unmatched:int,account_missing:int,income:float,expense:float}
*/
private function syncWithdrawals(string $from, string $to): array
{
$shops = $this->connection->table('fin_shops')
->orderBy('id')
->get(['id', 'shop_name', 'platform', 'account_short']);
[$shopByName, $shopsByNormalized] = $this->shopIndexes($shops);
$existingKeys = $this->existingWithdrawalKeys($from, $to, $shopByName);
$filters = $this->billingDetails->exportFilters([
'start' => $from,
'end' => $to,
'remark' => ['提现'],
]);
$columns = $this->billingDetails->exportColumns([
'record_code',
'store_name',
'finance_remark',
'bill_item',
'occurred_at',
'income',
'expense',
]);
$rows = [];
$processed = $writtenIncome = $writtenExpense = 0;
$duplicates = $unmatched = $accountMissing = 0;
$now = now();
foreach ($this->billingDetails->exportRows($filters, $columns) as $row) {
$processed++;
$date = substr(trim((string) ($row['occurred_at'] ?? '')), 0, 10);
$recordCode = trim((string) ($row['record_code'] ?? ''));
if ($date === '' || $recordCode === '') {
continue;
}
$sourcePlatform = trim((string) ($row['platform'] ?? ''));
$sourceStore = trim((string) ($row['store_name'] ?? ''));
$shop = $this->matchShop($sourceStore, $sourcePlatform, $shopByName, $shopsByNormalized);
if ($shop === null) {
$unmatched++;
continue;
}
$accountShort = trim((string) ($shop->account_short ?? ''));
if ($accountShort === '' || $accountShort === '/') {
$accountMissing++;
continue;
}
$incomeCents = (int) round((float) ($row['income'] ?? 0) * 100);
$expenseCents = (int) round((float) ($row['expense'] ?? 0) * 100);
$netCents = $incomeCents - $expenseCents;
if ($netCents === 0) {
continue;
}
// 平台提现是从平台账户流出、银行账户流入;平台退回则反向记账。
$targetIncomeCents = $netCents < 0 ? abs($netCents) : 0;
$targetExpenseCents = $netCents > 0 ? $netCents : 0;
$duplicateKey = implode('|', [
$date,
$accountShort,
$targetIncomeCents,
$targetExpenseCents,
]);
if (isset($existingKeys[$duplicateKey])) {
$duplicates++;
continue;
}
$detail = implode(' · ', array_values(array_filter([
$sourcePlatform !== '' ? $sourcePlatform . '账单提现' : '店铺账单提现',
trim((string) $shop->shop_name),
trim((string) ($row['bill_item'] ?? '')),
])));
$rowKey = hash('sha256', implode("\0", [
self::WITHDRAWAL_SOURCE,
$sourcePlatform,
$recordCode,
(string) $shop->shop_name,
]));
$rows[] = [
'txn_date' => $date,
'account_short' => $accountShort,
'detail' => mb_substr($detail, 0, 4000),
'income' => number_format($targetIncomeCents / 100, 2, '.', ''),
'expense' => number_format($targetExpenseCents / 100, 2, '.', ''),
'approval_no' => mb_substr(($sourcePlatform !== '' ? $sourcePlatform . ':' : '') . $recordCode, 0, 100),
'source' => self::WITHDRAWAL_SOURCE,
'source_value' => mb_substr((string) $shop->shop_name, 0, 200),
'process_type' => '店铺提现',
'row_key' => $rowKey,
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$writtenIncome += $targetIncomeCents;
$writtenExpense += $targetExpenseCents;
}
foreach (array_chunk($rows, 400) as $chunk) {
$this->connection->table('fin_transactions')->upsert(
$chunk,
['row_key'],
[
'txn_date', 'account_short', 'detail', 'income', 'expense', 'approval_no',
'source', 'source_value', 'process_type', 'synced_at', 'updated_at',
],
);
}
return [
'from' => $from,
'to' => $to,
'processed' => $processed,
'written' => count($rows),
'upstream_duplicates' => $duplicates,
'unmatched' => $unmatched,
'account_missing' => $accountMissing,
'income' => round($writtenIncome / 100, 2),
'expense' => round($writtenExpense / 100, 2),
];
}
/**
* @return array<string,string>
*/
private function existingWithdrawalKeys(string $from, string $to, array $shopByName): array
{
$rows = $this->connection->table('fin_transactions')
->whereBetween('txn_date', [$from, $to])
->where('source', '<>', self::WITHDRAWAL_SOURCE)
->where(function ($query): void {
$query->where('source', 'shop')->orWhere('process_type', '店铺提现');
})
->get(['txn_date', 'account_short', 'source_value', 'income', 'expense']);
$keys = [];
foreach ($rows as $row) {
$shop = $shopByName[trim((string) ($row->source_value ?? ''))]
?? $shopByName[trim((string) ($row->account_short ?? ''))]
?? null;
$account = trim((string) ($shop->account_short ?? $row->account_short ?? ''));
if ($account === '') {
continue;
}
$key = implode('|', [
substr((string) $row->txn_date, 0, 10),
$account,
(int) round((float) $row->income * 100),
(int) round((float) $row->expense * 100),
]);
$keys[$key] = true;
}
return $keys;
}
/**
* @return array{0:array<string,object>,1:array<string,list<object>>}
*/
private function shopIndexes($shops): array
{
$byName = [];
$byNormalized = [];
foreach ($shops as $shop) {
$name = trim((string) ($shop->shop_name ?? ''));
if ($name === '') {
continue;
}
$byName[$name] = $shop;
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($name);
$byNormalized[$normalized][] = $shop;
}
return [$byName, $byNormalized];
}
private function matchShop(string $storeName, string $platform, array $byName, array $byNormalized): ?object
{
if (isset($byName[$storeName])) {
return $byName[$storeName];
}
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
$candidates = $byNormalized[$normalized] ?? [];
if (count($candidates) === 1) {
return $candidates[0];
}
$matches = array_values(array_filter(
$candidates,
fn (object $shop): bool => $this->platformCompatible($platform, (string) ($shop->platform ?? '')),
));
return count($matches) === 1 ? $matches[0] : null;
}
private function platformCompatible(string $source, string $target): bool
{
$source = mb_strtolower(trim($source));
$target = mb_strtolower(trim($target));
if ($source === '' || $target === '' || str_contains($source, $target) || str_contains($target, $source)) {
return true;
}
foreach ([
['京东', '京东自营', '京东供销'],
['京喜', '京喜企业店'],
['淘系支付宝', '天猫', '淘宝', '阿里巴巴', '淘工厂'],
['淘系微信', '天猫', '淘宝'],
['抖音', '视频号小店'],
['唯品会', '唯品会MP'],
] as $aliases) {
$sourceHit = array_filter($aliases, static fn (string $alias): bool => str_contains($source, mb_strtolower($alias)));
$targetHit = array_filter($aliases, static fn (string $alias): bool => str_contains($target, mb_strtolower($alias)));
if ($sourceHit !== [] && $targetHit !== []) {
return true;
}
}
return false;
}
private function financialPlatform(string $sourcePlatform, string $storeName): string
{
$sourcePlatform = trim($sourcePlatform);
$storeName = trim($storeName);
foreach ([
'天猫' => '天猫',
'淘宝' => '淘宝',
'1688' => '阿里巴巴',
'阿里巴巴' => '阿里巴巴',
'淘工厂' => '淘工厂',
'京东' => '京东',
'京喜' => '京喜企业店',
'拼多多' => '拼多多',
'抖音' => '抖音小店',
'头条放心购' => '抖音小店',
'唯品会MP' => '唯品会MP',
'得物' => '得物',
'小红书' => '小红书',
'快手' => '快手小店',
] as $needle => $platform) {
if (str_contains($storeName, $needle) || str_contains($sourcePlatform, $needle)) {
return $platform;
}
}
return $sourcePlatform !== '' ? $sourcePlatform : '未填写';
}
private function financialShopStatus(mixed $status): string
{
return match (trim((string) $status)) {
'正常', '在营' => '在营',
'关店中', '暂停' => '暂停',
'已关店', '已注销' => '已注销',
default => trim((string) $status) ?: '待确认',
};
}
private function validDate(?string $value): ?string
{
$value = trim((string) $value);
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value ? $value : null;
}
}