From 63884f8e7e1dbc0e57532a8fe345d1a5cf77b18e Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Sat, 24 Oct 2026 20:27:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B4=A2=E5=8A=A1=E5=8F=B0=E8=B4=A6):=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=BA=97=E9=93=BA=E4=BD=99=E9=A2=9D=E5=92=8C?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=8F=90=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/SyncFinanceLedgerShopData.php | 59 +++ .../Controllers/Business/FinanceLedger.php | 41 ++ ...000_add_shop_balance_date_to_fin_shops.php | 37 ++ deploy/activate-incremental-release.sh | 4 +- deploy/activate-release.sh | 4 +- .../JushuitanShopBalanceRepository.php | 4 +- .../FinanceLedgerShopDataSynchronizer.php | 458 ++++++++++++++++++ .../FinanceLedgerTransactionWriter.php | 6 +- .../View/FinanceLedgerPageData.php | 16 + .../pages/financial-ledger/ledger.blade.php | 21 +- routes/admin.php | 4 + .../FinanceLedgerShopDataSynchronizerTest.php | 266 ++++++++++ 12 files changed, 908 insertions(+), 12 deletions(-) create mode 100644 app/Console/Commands/SyncFinanceLedgerShopData.php create mode 100644 database/migrations/2026_10_24_230000_add_shop_balance_date_to_fin_shops.php create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerShopDataSynchronizer.php create mode 100644 tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php diff --git a/app/Console/Commands/SyncFinanceLedgerShopData.php b/app/Console/Commands/SyncFinanceLedgerShopData.php new file mode 100644 index 00000000..1eff019a --- /dev/null +++ b/app/Console/Commands/SyncFinanceLedgerShopData.php @@ -0,0 +1,59 @@ +option('days') ?: 0); + $from = $this->option('from') ?: null; + if ($days > 0) { + $from = now()->subDays(max(1, $days) - 1)->toDateString(); + } + + try { + $result = $synchronizer->sync( + $from, + $this->option('to') ?: null, + ! $this->option('no-recalculate'), + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->table(['项目', '值'], [ + ['店铺余额日期', $result['balance']['date'] ?: '-'], + ['余额源记录', $result['balance']['source']], + ['补充确认店铺', $result['balance']['added']], + ['已更新店铺余额', $result['balance']['matched']], + ['店铺余额合计', number_format((float) $result['balance']['amount'], 2)], + ['提现处理笔数', $result['withdrawals']['processed']], + ['提现新增或更新', $result['withdrawals']['written']], + ['上游已存在提现', $result['withdrawals']['upstream_duplicates']], + ['店铺未匹配', $result['withdrawals']['unmatched']], + ['账户缺失', $result['withdrawals']['account_missing']], + ['提现收入合计', number_format((float) $result['withdrawals']['income'], 2)], + ['提现冲回支出', number_format((float) $result['withdrawals']['expense'], 2)], + ['每日流水行数', $result['recalculated']['flows'] ?? '-'], + ['PHP 峰值内存', number_format(memory_get_peak_usage(true) / 1048576, 1) . ' MiB'], + ]); + $this->info('财务台账店铺余额与提现同步完成。'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 0aabfda1..aa008265 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -17,10 +17,12 @@ use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; +use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer; use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter; use Modules\EcommerceFinance\View\FinanceLedgerPageData; use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; class FinanceLedger extends Controller { @@ -34,6 +36,45 @@ class FinanceLedger extends Controller return $template->download(); } + public function syncShopData(FinanceLedgerShopDataSynchronizer $synchronizer) + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以同步店铺余额和提现。'); + + $target = route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'ledger', + ]) . '?tab=shops'; + + try { + $result = $synchronizer->sync(now()->subDays(30)->toDateString()); + } catch (Throwable $exception) { + report($exception); + + return redirect()->to($target)->with('error', '店铺数据同步失败:' . $exception->getMessage()); + } + + $message = sprintf( + '已补充确认店铺 %s 个,同步上一晚店铺余额 %s 个,合计 %s 元;新增或更新提现 %s 笔,收入 %s 元%s。', + number_format((int) $result['balance']['added']), + number_format((int) $result['balance']['matched']), + number_format((float) $result['balance']['amount'], 2), + number_format((int) $result['withdrawals']['written']), + number_format((float) $result['withdrawals']['income'], 2), + $result['withdrawals']['upstream_duplicates'] > 0 + ? ',跳过已有记录 ' . number_format((int) $result['withdrawals']['upstream_duplicates']) . ' 笔' + : '', + ); + if ($result['withdrawals']['unmatched'] > 0 || $result['withdrawals']['account_missing'] > 0) { + $message .= sprintf( + ';店铺未匹配 %s 笔,账户缺失 %s 笔', + number_format((int) $result['withdrawals']['unmatched']), + number_format((int) $result['withdrawals']['account_missing']), + ); + } + + return redirect()->to($target)->with('success', $message); + } + public function unavailableBalanceTasks(Request $request) { $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); diff --git a/database/migrations/2026_10_24_230000_add_shop_balance_date_to_fin_shops.php b/database/migrations/2026_10_24_230000_add_shop_balance_date_to_fin_shops.php new file mode 100644 index 00000000..085c27cf --- /dev/null +++ b/database/migrations/2026_10_24_230000_add_shop_balance_date_to_fin_shops.php @@ -0,0 +1,37 @@ +connection()); + if (! $schema->hasTable('fin_shops') || $schema->hasColumn('fin_shops', 'shop_balance_date')) { + return; + } + + $schema->table('fin_shops', function (Blueprint $table): void { + $table->date('shop_balance_date')->nullable()->after('shop_balance'); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('fin_shops') || ! $schema->hasColumn('fin_shops', 'shop_balance_date')) { + return; + } + + $schema->table('fin_shops', function (Blueprint $table): void { + $table->dropColumn('shop_balance_date'); + }); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index b2fedfd9..f6d979a7 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -223,8 +223,8 @@ CRON_TZ=Asia/Shanghai # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 -# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。 -30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1 +# 财务台账:每天 02:30 同步 AI 库动账、上一晚店铺余额和平台提现,并重算流水。 +30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --days=7 --quiet' >/dev/null 2>&1 # 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。 * * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1 * * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 3ce8626d..2e30e623 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -227,8 +227,8 @@ CRON_TZ=Asia/Shanghai # 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。 15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1 */2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1 -# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。 -30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1 +# 财务台账:每天 02:30 同步 AI 库动账、上一晚店铺余额和平台提现,并重算流水。 +30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --days=7 --quiet' >/dev/null 2>&1 # 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。 * * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1 * * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1 diff --git a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php index bedb1218..c12f7234 100644 --- a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php +++ b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php @@ -52,7 +52,7 @@ class JushuitanShopBalanceRepository foreach ($stores as $store) { $names = array_values(array_unique(array_filter([ self::normalizeStoreName((string) ($store->shop_name ?? '')), - self::normalizeStoreName((string) ($store->erp_store_name ?? '')), + self::normalizeStoreName((string) ($store->erp_store_name ?? $store->shop_name ?? '')), ]))); $matches = $sourceRows->filter( @@ -64,7 +64,7 @@ class JushuitanShopBalanceRepository continue; } - $targetPlatform = self::platformKey((string) ($store->store_platform ?? '')); + $targetPlatform = self::platformKey((string) ($store->store_platform ?? $store->platform ?? '')); if ($targetPlatform !== null) { $matches = $matches->filter( static function (object $row) use ($targetPlatform): bool { diff --git a/modules/EcommerceFinance/Services/FinanceLedgerShopDataSynchronizer.php b/modules/EcommerceFinance/Services/FinanceLedgerShopDataSynchronizer.php new file mode 100644 index 00000000..fce19e1a --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerShopDataSynchronizer.php @@ -0,0 +1,458 @@ +connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @return array{ + * balance:array{date:?string,source:int,added:int,matched:int,amount:float}, + * 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): 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); + $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,matched:int,amount:float} + */ + public function syncShopBalances(?string $through = null): array + { + $through = $this->validDate($through) ?? now()->startOfDay()->subDay()->toDateString(); + $balanceDate = $this->shopBalances->latestBalanceDate(CarbonImmutable::parse($through)); + if ($balanceDate === null) { + return ['date' => null, 'source' => 0, 'added' => 0, 'matched' => 0, 'amount' => 0.0]; + } + + $shops = $this->connection->table('fin_shops') + ->orderBy('id') + ->get(['id', 'shop_name', 'platform', 'account_short']); + $added = $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); + $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); + } + } + + return [ + 'date' => $balanceDate, + 'source' => $this->shopBalances->countForDate($balanceDate), + 'added' => $added, + 'matched' => count($updates), + 'amount' => round(array_sum($updates), 2), + ]; + } + + private function addConfirmedMissingShops(string $balanceDate, $existingShops): int + { + $sourceRows = $this->connection->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', $balanceDate) + ->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] ?? []; + if (count($receivingMatches) !== 1) { + continue; + } + + $receiving = $receivingMatches[0]; + $bankLastFour = trim((string) ($receiving->bank_card_last_four ?? '')); + $payout = trim((string) ($receiving->withdrawal_account_name ?? '')); + if ($bankLastFour !== '') { + $payout = trim($payout . ' ' . $bankLastFour); + } + $insert[] = [ + 'shop_name' => mb_substr($name, 0, 200), + 'platform' => $this->financialPlatform((string) ($source->source_platform ?? ''), $name), + 'company_name' => trim((string) ($receiving->store_entity_name ?? '')) ?: null, + 'account_short' => null, + 'status' => trim((string) ($receiving->store_status ?? '')) ?: '在营', + 'payout_account' => $payout !== '' ? mb_substr($payout, 0, 100) : null, + 'shop_balance' => 0, + 'shop_balance_date' => null, + 'remark' => '由店铺收款账户和余额快照补充,待确认回款账户', + '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 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; + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php b/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php index c0d83e63..0a20bab3 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php @@ -15,6 +15,8 @@ class FinanceLedgerTransactionWriter { public const MANUAL_SOURCE = 'manual'; + public const SHOP_WITHDRAWAL_SOURCE = 'shop_bill'; + public function replace(array $transactions, bool $recalculate = true): array { return $this->persist($transactions, $recalculate, true); @@ -90,7 +92,7 @@ class FinanceLedgerTransactionWriter $connection->transaction(function () use ($connection, $rows, $now, $replaceExisting): void { if ($replaceExisting) { $connection->table('fin_transactions') - ->where('source', '<>', self::MANUAL_SOURCE) + ->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE]) ->delete(); } foreach (array_chunk($rows, 500) as $chunk) { @@ -127,7 +129,7 @@ class FinanceLedgerTransactionWriter { $stats = $connection->table('fin_transactions') ->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum') - ->where('source', '<>', self::MANUAL_SOURCE) + ->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE]) ->whereNotNull('source_value') ->where('source_value', '<>', '') ->groupBy('source', 'source_value') diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 3f68649e..fd44cef1 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -9,6 +9,8 @@ use App\Utilities\FinanceLedgerAccess; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository; /** * 财务台账页面数据。 @@ -49,6 +51,7 @@ class FinanceLedgerPageData 'ledgerAccountSummary' => $accounts['summary'], 'ledgerShops' => $shops['rows'], 'ledgerShopSummary' => $shops['summary'], + 'ledgerShopBalanceDate' => $this->latestShopBalanceDate(), 'ledgerFlows' => $flows['rows'], 'ledgerFlowSummary' => $flows['summary'], 'ledgerTransactions' => $transactions['rows'], @@ -480,6 +483,19 @@ class FinanceLedgerPageData ]; } + private function latestShopBalanceDate(): ?string + { + if (! Schema::connection($this->db()->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) { + return null; + } + + $latest = $this->db()->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '<=', now()->startOfDay()->subDay()->toDateString()) + ->max('balance_date'); + + return $latest === null ? null : substr((string) $latest, 0, 10); + } + private function accountOptions(): array { return $this->db()->table('fin_accounts') diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 85cfe216..6bbf39ab 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -54,7 +54,8 @@ $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $ledgerPageUrl = route('business.pages.show', ['section' => 'financial-operations', 'page' => 'ledger']); $tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]); - $sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入']; + $sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入']; + $shopBalanceDate = $ledgerShopBalanceDate ?? null; $manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? []; $transactionAccountHeaderOptions = $ledgerTransactionAccountHeaderOptions ?? []; $transactionDateHeaderOptions = $ledgerTransactionDateHeaderOptions ?? []; @@ -189,6 +190,7 @@ .ledger-filter-combobox__empty, .ledger-filter-combobox__more { margin:.25rem 0 0; padding:.45rem .5rem; border-top:1px solid #e8e3da; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.4; text-align:center; } .ledger-actions { display:flex; gap:.45rem; padding-bottom:.05rem; } + .ledger-store-sync { display:flex; align-items:center; gap:.55rem; margin:-.2rem 0 .65rem; color:var(--vip-content-muted, #595959); font-size:.7rem; } .ledger-edit-switch { position:relative; display:inline-flex; align-items:center; align-self:flex-end; gap:.45rem; height:2.15rem; padding:0 .65rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-muted, #595959); font-size:.72rem; font-weight:600; cursor:pointer; user-select:none; white-space:nowrap; } .ledger-edit-switch input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; } .ledger-edit-switch__track { position:relative; width:2.15rem; height:1.2rem; border-radius:999px; background:#d6d0c5; transition:background .16s ease; } @@ -541,7 +543,17 @@
refresh重置
-
店铺数{{ number_format((int) ($ledgerShopSummary['count'] ?? 0)) }}
店铺余额合计{{ $money($ledgerShopSummary['balance'] ?? 0) }}
余额口径所属公司账户合计(待确认)
+ @if ($canEditAccountStatus) +
+ @csrf + + {{ $shopBalanceDate ? '店铺余额日期:' . $shopBalanceDate : '尚未同步店铺余额' }} +
+ @endif +
店铺数{{ number_format((int) ($ledgerShopSummary['count'] ?? 0)) }}
店铺余额合计{{ $money($ledgerShopSummary['balance'] ?? 0) }}
余额口径{{ $shopBalanceDate ? '上一晚店铺余额(' . $shopBalanceDate . ')' : '尚未同步' }}
@foreach ($ledgerTableHeaders['shops'] as $header) @@ -557,7 +569,7 @@ ]) @endforeach - @forelse ($ledgerShops as $row)@empty@endforelse + @forelse ($ledgerShops as $row)@empty@endforelse
{{ $row->shop_name }}{{ $row->platform }}{{ $row->company_name }}{{ $row->account_short }}{{ $row->status }}{{ $row->payout_account }}{{ $money($row->shop_balance) }}{{ $row->remark }}
没有匹配的店铺数据。
{{ $row->shop_name }}{{ $row->platform }}{{ $row->company_name }}{{ $row->account_short }}{{ $row->status }}{{ $row->payout_account }}shop_balance_date ?? null) title="余额日期:{{ $row->shop_balance_date }}" @endif>{{ $money($row->shop_balance) }}{{ $row->remark }}
没有匹配的店铺数据。
@include('business.pages.financial-ledger.partials.pagination', [ 'rows' => $ledgerShops, @@ -777,7 +789,8 @@
取数边界:动账明细只读 AI 库 JSReportReplica.rpt 的视图,不读 base,不直连 JianSun;账户、店铺、公司、期初由财务系统单独导入。当前本地数据为 rpt 实时同步结果,期初日 {{ $ledgerOpeningDate }}。
流水口径:按账户逐日滚动,期末 = 期初 + 当日入账 − 当日出账;期初日无交易也保留一行。
- 店铺余额:当前按“所属公司名下所有账户最新期末余额合计”计算,待财务确认是否改为回款账户口径。 + 店铺余额:取聚水潭快照中每个店铺上一晚的最新余额;未匹配店铺保留原值并等待核对。
+ 平台提现:按“平台 + 店铺名称”匹配店铺台账的账户简称,提现金额作为对应银行账户收入入账;已由其他来源同步的同日同店同金额记录会自动跳过。
diff --git a/routes/admin.php b/routes/admin.php index f37b73aa..bfc967ee 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -696,6 +696,10 @@ Route::post('workspace/financial-ledger/transactions/manual', [\App\Http\Control ->middleware('business.page:financial-operations,ledger') ->name('business.financial-ledger.transactions.manual'); +Route::post('workspace/financial-ledger/shop-data/sync', [\App\Http\Controllers\Business\FinanceLedger::class, 'syncShopData']) + ->middleware('business.page:financial-operations,ledger') + ->name('business.financial-ledger.shop-data.sync'); + Route::post('workspace/financial-ledger/transactions/{id}/account', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateTransactionAccount']) ->whereNumber('id') ->middleware('business.page:financial-operations,ledger') diff --git a/tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php b/tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php new file mode 100644 index 00000000..4f3afbc3 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php @@ -0,0 +1,266 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + parent::tearDown(); + } + + public function test_shop_balance_uses_latest_balance_on_or_before_previous_night(): void + { + Carbon::setTestNow('2026-10-24 09:00:00'); + $connection = DB::connection('business_mirror'); + $connection->table('fin_shops')->insert([ + 'shop_name' => '京东-余额测试店', + 'platform' => '京东', + 'account_short' => '余额测试账户', + 'shop_balance' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->insertBalance($connection, '2026-10-22', '京东-余额测试店', 222.22); + $this->insertBalance($connection, '2026-10-23', '京东-余额测试店', 333.33); + $this->insertBalance($connection, '2026-10-24', '京东-余额测试店', 444.44); + + $result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23'); + + $this->assertSame('2026-10-23', $result['date']); + $this->assertSame(1, $result['matched']); + $shop = $connection->table('fin_shops')->where('shop_name', '京东-余额测试店')->first(); + $this->assertSame(333.33, (float) $shop->shop_balance); + $this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10)); + } + + public function test_balance_sync_adds_confirmed_store_that_is_missing_from_shop_ledger(): void + { + Carbon::setTestNow('2026-10-24 09:00:00'); + StoreReceivingAccount::withoutGlobalScopes()->create([ + 'company_id' => $this->company->id, + 'store_platform' => '抖音', + 'store_name' => '抖音-待补充店铺', + 'store_entity_name' => '补充主体有限公司', + 'receiving_method' => '银行卡', + ]); + $this->insertBalance( + DB::connection('business_mirror'), + '2026-10-23', + '抖音-待补充店铺', + 765.43, + '头条放心购', + ); + + $result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23'); + + $this->assertSame(1, $result['added']); + $shop = DB::connection('business_mirror')->table('fin_shops') + ->where('shop_name', '抖音-待补充店铺') + ->first(); + $this->assertNotNull($shop); + $this->assertSame('抖音小店', $shop->platform); + $this->assertSame('补充主体有限公司', $shop->company_name); + $this->assertNull($shop->account_short); + $this->assertSame(765.43, (float) $shop->shop_balance); + $this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10)); + } + + public function test_withdrawal_rows_are_mapped_to_shop_account_income_and_are_idempotent(): void + { + Carbon::setTestNow('2026-10-24 09:00:00'); + $connection = DB::connection('business_mirror'); + $connection->table('fin_shops')->insert([ + 'shop_name' => '京东-提现测试店', + 'platform' => '京东', + 'account_short' => '提现测试账户', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->bindWithdrawalRows([ + [ + 'record_code' => 'JD-WITHDRAW-1', + 'store_name' => '京东-提现测试店', + 'platform' => '京东', + 'bill_item' => '提现', + 'finance_remark' => '提现', + 'occurred_at' => '2026-10-23 20:00:00', + 'income' => '0.00', + 'expense' => '123.45', + ], + ], 2); + + $synchronizer = app(FinanceLedgerShopDataSynchronizer::class); + $first = $synchronizer->sync('2026-10-23', '2026-10-23', false); + $second = $synchronizer->sync('2026-10-23', '2026-10-23', false); + + $this->assertSame(1, $first['withdrawals']['written']); + $this->assertSame(1, $second['withdrawals']['written']); + $this->assertDatabaseCount('fin_transactions', 1, 'business_mirror'); + $transaction = $connection->table('fin_transactions')->first(); + $this->assertSame('shop_bill', $transaction->source); + $this->assertSame('提现测试账户', $transaction->account_short); + $this->assertSame('店铺提现', $transaction->process_type); + $this->assertSame(123.45, (float) $transaction->income); + $this->assertSame(0.0, (float) $transaction->expense); + } + + public function test_withdrawal_is_skipped_when_same_cashflow_was_already_synced_from_upstream(): void + { + Carbon::setTestNow('2026-10-24 09:00:00'); + $connection = DB::connection('business_mirror'); + $connection->table('fin_shops')->insert([ + 'shop_name' => '京东-重复提现店', + 'platform' => '京东', + 'account_short' => '重复提现账户', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $connection->table('fin_transactions')->insert([ + 'txn_date' => '2026-10-23', + 'account_short' => '重复提现账户', + 'detail' => '上游已同步提现', + 'income' => 88.00, + 'expense' => 0, + 'approval_no' => 'UPSTREAM-1', + 'source' => 'shop', + 'source_value' => '京东-重复提现店', + 'process_type' => '店铺提现', + 'row_key' => hash('sha256', 'upstream-duplicate'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->bindWithdrawalRows([ + [ + 'record_code' => 'JD-WITHDRAW-2', + 'store_name' => '京东-重复提现店', + 'platform' => '京东', + 'bill_item' => '提现', + 'finance_remark' => '提现', + 'occurred_at' => '2026-10-23 21:00:00', + 'income' => '0.00', + 'expense' => '88.00', + ], + ]); + + $result = app(FinanceLedgerShopDataSynchronizer::class)->sync('2026-10-23', '2026-10-23', false); + + $this->assertSame(0, $result['withdrawals']['written']); + $this->assertSame(1, $result['withdrawals']['upstream_duplicates']); + $this->assertSame(0, $connection->table('fin_transactions')->where('source', 'shop_bill')->count()); + } + + public function test_full_upstream_replacement_preserves_generated_shop_withdrawals(): void + { + $connection = DB::connection('business_mirror'); + $connection->table('fin_transactions')->insert([ + 'txn_date' => '2026-10-23', + 'account_short' => '保留提现账户', + 'detail' => '页面生成提现', + 'income' => 50, + 'expense' => 0, + 'approval_no' => 'JD:KEEP-1', + 'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE, + 'source_value' => '京东-保留提现店', + 'process_type' => '店铺提现', + 'row_key' => hash('sha256', 'keep-shop-withdrawal'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(FinanceLedgerTransactionWriter::class)->replace([ + [ + 'txn_date' => '2026-10-23', + 'account_short' => '上游账户', + 'detail' => '上游动账', + 'income' => 10, + 'expense' => 0, + 'approval_no' => 'UPSTREAM-2', + 'source' => 'dingtalk', + 'source_value' => '上游来源', + 'process_type' => '费用', + ], + ], false); + + $this->assertDatabaseHas('fin_transactions', [ + 'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE, + 'approval_no' => 'JD:KEEP-1', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_transactions', [ + 'source' => 'dingtalk', + 'approval_no' => 'UPSTREAM-2', + ], 'business_mirror'); + } + + private function insertBalance( + $connection, + string $date, + string $storeName, + float $balance, + string $platform = '京东', + ): void + { + $connection->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => $date, + 'source_scope' => 'all', + 'source_platform' => $platform, + 'store_name' => $storeName, + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), + 'platform_account_id' => null, + 'balance' => $balance, + 'source_row_key' => hash('sha256', $date . '|' . $storeName . '|' . $balance), + 'source_file_name' => 'fixture.xlsx', + 'source_sha256' => hash('sha256', 'fixture'), + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function bindWithdrawalRows(array $rows, int $calls = 1): void + { + $billing = Mockery::mock(BillingDetailsPageData::class); + $billing->shouldReceive('exportFilters') + ->times($calls) + ->andReturnUsing(static fn (array $input): array => $input + ['remark' => ['提现']]); + $billing->shouldReceive('exportColumns') + ->times($calls) + ->andReturn([ + 'record_code' => ['记录编码', 'text'], + 'store_name' => ['店铺', 'text'], + 'finance_remark' => ['财务备注', 'text'], + 'bill_item' => ['账单项目', 'tag'], + 'occurred_at' => ['发生时间', 'text'], + 'income' => ['收入金额', 'num'], + 'expense' => ['支出金额', 'num'], + ]); + $billing->shouldReceive('exportRows') + ->times($calls) + ->andReturnUsing(static function () use ($rows): \Generator { + yield from $rows; + }); + + app()->instance(BillingDetailsPageData::class, $billing); + } +} -- 2.47.2