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 */ 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,1:array>} */ 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; } }