From 0b69f30714d9e2dd4e99f189e4b6b94f2152ec2e Mon Sep 17 00:00:00 2001 From: 92451 <924518135@qq.com> Date: Sun, 25 Oct 2026 17:06:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=BD=95=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=BA=97=E9=93=BA=E4=BD=99=E9=A2=9D=E5=B9=B6?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。 PR #552 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com> --- app/Business/WithdrawalManagementReport.php | 15 +- .../WithdrawalManagementSpreadsheet.php | 45 ++- .../Commands/ImportJushuitanShopBalances.php | 32 +- ...ce_to_jushuitan_shop_balance_snapshots.php | 44 +++ .../FinanceBillBalanceRepository.php | 289 ++++++++++++++++ .../JushuitanShopBalanceRepository.php | 33 +- .../FinanceBillShopBalanceFallback.php | 280 +++++++++++++++ .../Services/JushuitanShopBalanceImporter.php | 3 + .../withdrawal-management.blade.php | 24 +- .../FinanceBillShopBalanceFallbackTest.php | 326 ++++++++++++++++++ tests/Feature/Business/ManagementCrudTest.php | 2 +- .../WithdrawalManagementImportExportTest.php | 5 +- 12 files changed, 1067 insertions(+), 31 deletions(-) create mode 100644 database/migrations/2026_10_25_190000_add_data_source_to_jushuitan_shop_balance_snapshots.php create mode 100644 modules/EcommerceFinance/Repositories/FinanceBillBalanceRepository.php create mode 100644 modules/EcommerceFinance/Services/FinanceBillShopBalanceFallback.php create mode 100644 tests/Feature/Business/FinanceBillShopBalanceFallbackTest.php diff --git a/app/Business/WithdrawalManagementReport.php b/app/Business/WithdrawalManagementReport.php index 3557021b..2d3c9ba5 100644 --- a/app/Business/WithdrawalManagementReport.php +++ b/app/Business/WithdrawalManagementReport.php @@ -36,12 +36,12 @@ class WithdrawalManagementReport * @return array{ * filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string}, * platforms:list,paths:list,storeEntities:list,rows:Collection, - * balances:array, - * balancesByDate:array>, + * balances:array, + * balancesByDate:array>, * balanceDays:list, * alerts:array, * reserveStandards:array,withdrawalAmounts:array,compliance:array, - * summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int}, + * summary:array{source:int,matched:int,finance:int,ambiguous:int,unmatched:int,not_ready:int}, * balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list,sort:array{field:string,direction:string} * } */ @@ -91,6 +91,10 @@ class WithdrawalManagementReport $balanceCounts = collect($balances)->countBy( static fn (array $match): string => $match['status'], ); + $financeCount = collect($balances)->filter( + static fn (array $match): bool => ($match['data_source'] ?? null) + === JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, + )->count(); if ($filters['balanceStatus'] !== '') { $rows = $rows->filter( @@ -162,6 +166,7 @@ class WithdrawalManagementReport 'summary' => [ 'source' => $this->shopBalances->countForDate($balanceDate), 'matched' => (int) $balanceCounts->get('matched', 0), + 'finance' => $financeCount, 'ambiguous' => (int) $balanceCounts->get('ambiguous', 0), 'unmatched' => (int) $balanceCounts->get('unmatched', 0), 'not_ready' => (int) $balanceCounts->get('not_ready', 0), @@ -228,7 +233,7 @@ class WithdrawalManagementReport /** * @param Collection $rows - * @param array $balances + * @param array $balances * @return Collection */ private function sortByBalanceStatus(Collection $rows, array $balances): Collection @@ -251,7 +256,7 @@ class WithdrawalManagementReport /** * @param Collection $rows * @param array{field:string,direction:string} $sort - * @param array> $balancesByDate + * @param array> $balancesByDate * @param list $balanceDays * @param array $alerts * @param array $reserveStandards diff --git a/app/Business/WithdrawalManagementSpreadsheet.php b/app/Business/WithdrawalManagementSpreadsheet.php index a510680d..b5980ebe 100644 --- a/app/Business/WithdrawalManagementSpreadsheet.php +++ b/app/Business/WithdrawalManagementSpreadsheet.php @@ -51,8 +51,8 @@ class WithdrawalManagementSpreadsheet /** * @param array{ * rows:\Illuminate\Support\Collection, - * balances:array, - * balancesByDate:array>, + * balances:array, + * balancesByDate:array>, * balanceDays:list, * alerts:array, * reserveStandards:array,withdrawalAmounts:array,compliance:array, @@ -432,12 +432,20 @@ class WithdrawalManagementSpreadsheet static fn (array $day): string => (string) $day['label'], $report['balanceDays'], ); - $headers = [...self::CONFIG_HEADERS, ...$balanceHeaders, '余额预警', '预留标准', '提现金额', '是否达标']; + $headers = [ + ...self::CONFIG_HEADERS, + ...$balanceHeaders, + '余额来源', + '余额预警', + '预留标准', + '提现金额', + '是否达标', + ]; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('提现管理'); $this->writeHeader($sheet, $headers); - $this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 18, 18, 18, 14]); + $this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 32, 18, 18, 18, 14]); $rowNumber = 2; foreach ($report['rows'] as $row) { @@ -474,22 +482,35 @@ class WithdrawalManagementSpreadsheet } } + $sourceParts = []; + foreach ($report['balanceDays'] as $day) { + $match = $report['balancesByDate'][(int) $row->id][$day['date']] + ?? ['status' => 'not_ready']; + $sourceParts[] = str_replace('店铺余额', '', (string) $day['label']) . ':' . match ($match['status']) { + 'matched' => ($match['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭', + 'ambiguous' => '多条匹配', + 'unmatched' => '未匹配', + default => '待同步', + }; + } + $sheet->setCellValueExplicit([11, $rowNumber], implode(';', $sourceParts), DataType::TYPE_STRING); + $sheet->setCellValueExplicit( - [11, $rowNumber], + [12, $rowNumber], $alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'), DataType::TYPE_STRING, ); if ($reserve === null) { - $sheet->setCellValueExplicit([12, $rowNumber], '待配置', DataType::TYPE_STRING); + $sheet->setCellValueExplicit([13, $rowNumber], '待配置', DataType::TYPE_STRING); } else { - $sheet->setCellValue([12, $rowNumber], $reserve); + $sheet->setCellValue([13, $rowNumber], $reserve); } if ($withdrawalAmount === null) { - $sheet->setCellValueExplicit([13, $rowNumber], '待接入', DataType::TYPE_STRING); + $sheet->setCellValueExplicit([14, $rowNumber], '待接入', DataType::TYPE_STRING); } else { - $sheet->setCellValue([13, $rowNumber], $withdrawalAmount); + $sheet->setCellValue([14, $rowNumber], $withdrawalAmount); } - $sheet->setCellValueExplicit([14, $rowNumber], match ($compliance) { + $sheet->setCellValueExplicit([15, $rowNumber], match ($compliance) { 'yes' => '是', 'no' => '否', default => '待核验', @@ -499,9 +520,9 @@ class WithdrawalManagementSpreadsheet if ($rowNumber > 2) { $sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00'); - $sheet->getStyle('L2:M' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00'); + $sheet->getStyle('M2:N' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00'); $sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); - $sheet->getStyle('L2:M' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); + $sheet->getStyle('M2:N' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); } $sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@'); $sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@'); diff --git a/app/Console/Commands/ImportJushuitanShopBalances.php b/app/Console/Commands/ImportJushuitanShopBalances.php index 32a8ad6d..8ebce483 100644 --- a/app/Console/Commands/ImportJushuitanShopBalances.php +++ b/app/Console/Commands/ImportJushuitanShopBalances.php @@ -5,6 +5,7 @@ namespace App\Console\Commands; use App\Business\WithdrawalBalanceAlertSynchronizer; use App\Models\Business\StoreReceivingAccount; use Illuminate\Console\Command; +use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback; use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter; use Throwable; @@ -16,11 +17,12 @@ class ImportJushuitanShopBalances extends Command {--scope=all : 来源范围,同一日期同一范围会被原子替换} {--dry-run : 只校验和统计,不写入共享业务库}'; - protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照'; + protected $description = '导入聚水潭店铺余额,并用财务账单补录缺失店铺'; public function handle( JushuitanShopBalanceImporter $importer, WithdrawalBalanceAlertSynchronizer $alertSynchronizer, + FinanceBillShopBalanceFallback $financeFallback, ): int { try { @@ -45,6 +47,32 @@ class ImportJushuitanShopBalances extends Command $result['replaced'], )); + $financeFallbackFailed = false; + try { + $fallback = $financeFallback->fillMissing( + $result['balance_date'], + (string) $this->option('scope'), + (bool) $result['dry_run'], + ); + if (! $fallback['supported']) { + $this->warn('账单余额回填:当前来源的店铺收款账户账单没有累计账户余额字段,跳过回填。'); + } elseif ($fallback['missing'] === 0) { + $this->info('账单余额回填:聚水潭余额没有缺失店铺。'); + } else { + $this->info(sprintf( + '账单余额回填:缺失 %d 家,匹配 %d 家,写入 %d 条,仍有 %d 家待补。', + $fallback['missing'], + $fallback['resolved'], + $fallback['filled'], + $fallback['unresolved'], + )); + } + } catch (Throwable $exception) { + report($exception); + $this->error('财务系统店铺收款账户账单余额回填失败,聚水潭余额已保留,财务回填未覆盖。'); + $financeFallbackFailed = true; + } + if (! $result['dry_run']) { $companyIds = StoreReceivingAccount::withoutGlobalScopes() ->whereNull('deleted_at') @@ -73,6 +101,6 @@ class ImportJushuitanShopBalances extends Command } } - return self::SUCCESS; + return $financeFallbackFailed ? self::FAILURE : self::SUCCESS; } } diff --git a/database/migrations/2026_10_25_190000_add_data_source_to_jushuitan_shop_balance_snapshots.php b/database/migrations/2026_10_25_190000_add_data_source_to_jushuitan_shop_balance_snapshots.php new file mode 100644 index 00000000..485f635e --- /dev/null +++ b/database/migrations/2026_10_25_190000_add_data_source_to_jushuitan_shop_balance_snapshots.php @@ -0,0 +1,44 @@ +connection()); + if (! $schema->hasTable('jushuitan_shop_balance_snapshots') + || $schema->hasColumn('jushuitan_shop_balance_snapshots', 'data_source')) { + return; + } + + $schema->table('jushuitan_shop_balance_snapshots', function (Blueprint $table): void { + $table->string('data_source', 32)->default('jushuitan')->after('source_scope'); + $table->index( + ['balance_date', 'source_scope', 'data_source'], + 'jst_shop_balance_date_scope_source_index', + ); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if (! $schema->hasTable('jushuitan_shop_balance_snapshots') + || ! $schema->hasColumn('jushuitan_shop_balance_snapshots', 'data_source')) { + return; + } + + $schema->table('jushuitan_shop_balance_snapshots', function (Blueprint $table): void { + $table->dropIndex('jst_shop_balance_date_scope_source_index'); + $table->dropColumn('data_source'); + }); + } + + private function connection(): string + { + return (string) config('upstream_mirrors.connection', 'business_mirror'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/FinanceBillBalanceRepository.php b/modules/EcommerceFinance/Repositories/FinanceBillBalanceRepository.php new file mode 100644 index 00000000..229c7893 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/FinanceBillBalanceRepository.php @@ -0,0 +1,289 @@ +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 + */ + 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 */ + 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 */ + 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 $storeNames + * @param list|null $generations + * @return Collection + */ + 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 $rows + * @return list + */ + 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; + } +} diff --git a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php index 72850142..17041217 100644 --- a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php +++ b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php @@ -10,6 +10,8 @@ use Illuminate\Support\Facades\Schema; class JushuitanShopBalanceRepository { public const TABLE = 'jushuitan_shop_balance_snapshots'; + public const DATA_SOURCE_JUSHUITAN = 'jushuitan'; + public const DATA_SOURCE_FINANCE = 'finance'; private readonly ConnectionInterface $connection; @@ -22,7 +24,7 @@ class JushuitanShopBalanceRepository /** * @param iterable $stores - * @return array + * @return array */ public function matchesForStores(iterable $stores, string $balanceDate): array { @@ -37,9 +39,13 @@ class JushuitanShopBalanceRepository /** * @param iterable $stores * @param list $balanceDates - * @return array> + * @return array> */ - public function matchesForStoresOnDates(iterable $stores, array $balanceDates): array + public function matchesForStoresOnDates( + iterable $stores, + array $balanceDates, + ?string $dataSource = null, + ): array { $stores = is_array($stores) ? array_values($stores) : iterator_to_array($stores, false); $balanceDates = array_values(array_unique(array_filter( @@ -61,10 +67,14 @@ class JushuitanShopBalanceRepository $sourceRowsByDate = $this->connection->table(self::TABLE) ->whereIn('balance_date', $balanceDates) + ->when( + $dataSource !== null, + static fn ($query) => $query->where('data_source', $dataSource), + ) ->orderBy('id') ->get([ - 'id', 'balance_date', 'source_platform', 'store_name', 'normalized_store_name', - 'platform_account_id', 'balance', + 'id', 'balance_date', 'data_source', 'source_platform', 'store_name', + 'normalized_store_name', 'platform_account_id', 'balance', ]) ->groupBy(static fn (object $row): string => substr((string) $row->balance_date, 0, 10)); @@ -88,6 +98,13 @@ class JushuitanShopBalanceRepository continue; } + $jushuitanMatches = $matches->filter( + static fn (object $row): bool => (string) $row->data_source === self::DATA_SOURCE_JUSHUITAN, + )->values(); + if ($jushuitanMatches->isNotEmpty()) { + $matches = $jushuitanMatches; + } + $targetPlatform = self::platformKey((string) ($store->store_platform ?? $store->platform ?? '')); if ($targetPlatform !== null) { $matches = $matches->filter( @@ -116,6 +133,7 @@ class JushuitanShopBalanceRepository 'balance' => (float) $match->balance, 'source_store_name' => (string) $match->store_name, 'source_platform' => $match->source_platform === null ? null : (string) $match->source_platform, + 'data_source' => (string) $match->data_source, ]; } } @@ -214,7 +232,7 @@ class JushuitanShopBalanceRepository return str_ends_with(mb_strtolower(trim($storeName)), '1688') ? '1688' : null; } - private static function platformKey(string $platform): ?string + public static function platformKey(string $platform): ?string { $platform = mb_strtolower(trim($platform)); foreach ([ @@ -241,7 +259,7 @@ class JushuitanShopBalanceRepository return null; } - /** @return array{status:string,balance:null,source_store_name:null,source_platform:null} */ + /** @return array{status:string,balance:null,source_store_name:null,source_platform:null,data_source:null} */ private function emptyMatch(string $status): array { return [ @@ -249,6 +267,7 @@ class JushuitanShopBalanceRepository 'balance' => null, 'source_store_name' => null, 'source_platform' => null, + 'data_source' => null, ]; } diff --git a/modules/EcommerceFinance/Services/FinanceBillShopBalanceFallback.php b/modules/EcommerceFinance/Services/FinanceBillShopBalanceFallback.php new file mode 100644 index 00000000..0821f6a5 --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceBillShopBalanceFallback.php @@ -0,0 +1,280 @@ + ['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 */ + 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 */ + 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> $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, + ]; + } +} diff --git a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php index a4528aa5..82a9e29a 100644 --- a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php +++ b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php @@ -86,6 +86,7 @@ class JushuitanShopBalanceImporter $existing = $this->connection->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', $selectedDate) ->where('source_scope', $sourceScope) + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN) ->count(); if (! $dryRun) { @@ -99,6 +100,7 @@ class JushuitanShopBalanceImporter $this->connection->table(JushuitanShopBalanceRepository::TABLE) ->where('balance_date', $selectedDate) ->where('source_scope', $sourceScope) + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN) ->delete(); foreach (array_chunk($rows, 500) as $chunk) { @@ -106,6 +108,7 @@ class JushuitanShopBalanceImporter static fn (array $row): array => $row + [ 'balance_date' => $selectedDate, 'source_scope' => $sourceScope, + 'data_source' => JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, 'source_file_name' => $sourceName, 'source_sha256' => $sourceSha256, 'imported_at' => $now, diff --git a/resources/views/business/pages/fund-management/withdrawal-management.blade.php b/resources/views/business/pages/fund-management/withdrawal-management.blade.php index 1f2e31a8..311df6e7 100644 --- a/resources/views/business/pages/fund-management/withdrawal-management.blade.php +++ b/resources/views/business/pages/fund-management/withdrawal-management.blade.php @@ -52,6 +52,9 @@
余额源 {{ number_format($withdrawalBalanceSummary['source']) }} 条 已显示余额 {{ number_format($withdrawalBalanceSummary['matched']) }} 条 + @if (($withdrawalBalanceSummary['finance'] ?? 0) > 0) + 财务账单补录 {{ number_format($withdrawalBalanceSummary['finance']) }} 条 + @endif @if ($withdrawalBalanceSummary['ambiguous'] > 0) 多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条 @endif @@ -184,6 +187,9 @@ foreach ($withdrawalBalanceDays as $day) { $dayMatch = $withdrawalBalancesByDate[(int) $row->id][$day['date']] ?? ['status' => 'not_ready', 'balance' => null]; + $dataSource = $dayMatch['status'] === 'matched' + ? (($dayMatch['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭') + : null; $balanceCells[$day['key']] = [ 'matched' => $dayMatch['status'] === 'matched', 'text' => match ($dayMatch['status']) { @@ -192,8 +198,9 @@ 'unmatched' => '未匹配', default => '待同步', }, + 'source' => $dataSource, 'title' => $dayMatch['status'] === 'matched' - ? '聚水潭店铺:' . $dayMatch['source_store_name'] . ';余额日期:' . $day['date'] + ? '数据来源:' . $dataSource . ';店铺:' . $dayMatch['source_store_name'] . ';余额日期:' . $day['date'] : '余额日期:' . $day['date'], ]; } @@ -214,7 +221,17 @@ {{ $row->withdrawal_account_name ?: '未配置' }} {{ $bankCard }} @foreach ($withdrawalBalanceDays as $day) - {{ $balanceCells[$day['key']]['text'] }} + + + {{ $balanceCells[$day['key']]['text'] }} + @if ($balanceCells[$day['key']]['source']) + $balanceCells[$day['key']]['source'] === '财务系统', + ])>{{ $balanceCells[$day['key']]['source'] }} + @endif + + @endforeach @if ($alert !== null) @@ -437,6 +454,9 @@ .withdrawal-management__heading { display:flex; min-width:0; flex-direction:column; gap:.55rem; } .withdrawal-management__summary { display:flex; flex-wrap:wrap; gap:.35rem; } .withdrawal-alert-summary { border-color:#d8b15d; background:#fff7df; color:#935f07; } + .withdrawal-balance-value { display:inline-flex; align-items:center; justify-content:flex-end; gap:.35rem; white-space:nowrap; } + .withdrawal-balance-source { display:inline-flex; min-height:1.1rem; align-items:center; border:1px solid #d8d2c7; border-radius:999px; background:#f8f6f1; padding:0 .32rem; color:#595959; font-size:.625rem; font-weight:500; line-height:1; } + .withdrawal-balance-source.is-finance { border-color:#b9954f; background:#f7f0de; color:#856632; } .withdrawal-management tbody tr.is-withdrawal-warning > td { background:#fffdf5; } .withdrawal-alert-badge { display:inline-flex; align-items:center; gap:.25rem; white-space:nowrap; color:#935f07; font-size:.75rem; font-weight:600; text-decoration:none; } .withdrawal-alert-badge:hover, .withdrawal-alert-badge:focus-visible { color:#6e4603; text-decoration:underline; outline:none; } diff --git a/tests/Feature/Business/FinanceBillShopBalanceFallbackTest.php b/tests/Feature/Business/FinanceBillShopBalanceFallbackTest.php new file mode 100644 index 00000000..e60f4fac --- /dev/null +++ b/tests/Feature/Business/FinanceBillShopBalanceFallbackTest.php @@ -0,0 +1,326 @@ + */ + private array $temporaryFiles = []; + + protected function setUp(): void + { + parent::setUp(); + + DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->delete(); + $mirror = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + if (! $mirror->hasTable('taobao_alipay_bill_rows')) { + $mirror->create('taobao_alipay_bill_rows', function (Blueprint $table): void { + $table->string('record_code', 191)->primary(); + $table->string('store_name')->nullable(); + $table->string('alipay_account')->nullable(); + $table->dateTime('occurred_at'); + $table->decimal('balance_amount', 20, 6)->default(0); + }); + } else { + DB::connection('business_mirror')->table('taobao_alipay_bill_rows')->delete(); + } + DB::connection()->table('business_store_receiving_accounts')->delete(); + } + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $path) { + if (is_file($path)) { + unlink($path); + } + } + + parent::tearDown(); + } + + public function test_finance_bill_fills_only_missing_jushuitan_store_and_marks_source(): void + { + Carbon::setTestNow('2026-10-24 10:00:00'); + + try { + $jushuitanStore = $this->store('天猫', '聚水潭已有店'); + $missingStore = $this->store('天猫', '财务账单补录店'); + $this->insertSnapshot($jushuitanStore->erp_store_name, 10000, 'jushuitan'); + + $finance = Mockery::mock(FinanceBillBalanceRepository::class); + $finance->shouldReceive('latestBalances') + ->twice() + ->with('taobao', '2026-10-23', [$missingStore->erp_store_name]) + ->andReturn([$this->financeRow($missingStore->erp_store_name, 12345.67)]); + $this->app->instance(FinanceBillBalanceRepository::class, $finance); + + $fallback = $this->app->make(FinanceBillShopBalanceFallback::class); + $first = $fallback->fillMissing('2026-10-23', 'taobao'); + $second = $fallback->fillMissing('2026-10-23', 'taobao'); + + $this->assertSame(1, $first['missing']); + $this->assertSame(1, $first['resolved']); + $this->assertSame(1, $first['filled']); + $this->assertSame(1, $second['filled']); + $this->assertSame(2, DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '2026-10-23') + ->where('source_scope', 'taobao') + ->count()); + $this->assertSame(1, DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) + ->count()); + + $matches = $this->app->make(JushuitanShopBalanceRepository::class)->matchesForStores( + [$jushuitanStore, $missingStore], + '2026-10-23', + ); + $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $matches[$jushuitanStore->id]['data_source']); + $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, $matches[$missingStore->id]['data_source']); + $this->assertSame(12345.67, $matches[$missingStore->id]['balance']); + } finally { + Carbon::setTestNow(); + } + } + + public function test_existing_jushuitan_balance_removes_stale_finance_fallback(): void + { + $store = $this->store('京东', '聚水潭恢复店'); + $this->insertSnapshot($store->erp_store_name, 8000, 'jushuitan'); + $this->insertSnapshot($store->erp_store_name, 7000, 'finance'); + + $finance = Mockery::mock(FinanceBillBalanceRepository::class); + $finance->shouldNotReceive('latestBalances'); + $this->app->instance(FinanceBillBalanceRepository::class, $finance); + + $result = $this->app->make(FinanceBillShopBalanceFallback::class) + ->fillMissing('2026-10-23', 'jd-wallet'); + + $this->assertSame(0, $result['missing']); + $this->assertSame(0, DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) + ->count()); + $match = $this->app->make(JushuitanShopBalanceRepository::class) + ->matchesForStores([$store], '2026-10-23')[$store->id]; + $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $match['data_source']); + $this->assertSame(8000.0, $match['balance']); + } + + public function test_pinduoduo_without_cumulative_balance_is_not_filled_from_transactions(): void + { + $store = $this->store('拼多多', '拼多多无余额店'); + + $finance = Mockery::mock(FinanceBillBalanceRepository::class); + $finance->shouldNotReceive('latestBalances'); + $this->app->instance(FinanceBillBalanceRepository::class, $finance); + + $result = $this->app->make(FinanceBillShopBalanceFallback::class) + ->fillMissing('2026-10-23', 'pinduoduo'); + + $this->assertFalse($result['supported']); + $this->assertSame('finance_bill_has_no_account_balance', $result['reason']); + $this->assertSame(1, $result['missing']); + $this->assertSame(0, $result['filled']); + $this->assertSame(0, DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('normalized_store_name', JushuitanShopBalanceRepository::normalizeStoreName($store->erp_store_name)) + ->count()); + } + + public function test_finance_source_failure_keeps_previous_fallback_rows(): void + { + $store = $this->store('天猫', '财务源失败保留店'); + $this->insertSnapshot($store->erp_store_name, 6000, 'finance'); + + $finance = Mockery::mock(FinanceBillBalanceRepository::class); + $finance->shouldReceive('latestBalances') + ->once() + ->with('taobao', '2026-10-23', [$store->erp_store_name]) + ->andThrow(new RuntimeException('source unavailable')); + $this->app->instance(FinanceBillBalanceRepository::class, $finance); + + try { + $this->app->make(FinanceBillShopBalanceFallback::class) + ->fillMissing('2026-10-23', 'taobao'); + $this->fail('Expected source exception.'); + } catch (RuntimeException $exception) { + $this->assertSame('source unavailable', $exception->getMessage()); + } + + $this->assertSame(6000.0, (float) DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) + ->value('balance')); + } + + public function test_import_command_fills_missing_store_from_finance_bill_and_marks_sources(): void + { + $jushuitanStore = $this->store('天猫', '聚水潭自动补录已有店'); + $missingStore = $this->store('天猫', '财务账单自动补录店'); + $file = $this->jushuitanJson([$jushuitanStore->erp_store_name]); + + $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-22 20:00:00', 12000); + $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-23 23:59:59', 13500); + $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-b', '2026-10-23 23:59:30', 500); + $this->insertTaobaoBillRow($missingStore->erp_store_name, 'account-a', '2026-10-24 00:00:00', 99999); + + $financeBalances = $this->app->make(FinanceBillBalanceRepository::class) + ->latestBalances('taobao', '2026-10-23', [$missingStore->erp_store_name]); + $this->assertCount(1, $financeBalances); + $this->assertSame(14000.0, $financeBalances[0]['balance']); + + $this->artisan('business:import-jushuitan-shop-balances', [ + 'path' => $file, + '--balance-date' => '2026-10-23', + '--scope' => 'taobao', + ])->assertExitCode(0); + + $matches = $this->app->make(JushuitanShopBalanceRepository::class)->matchesForStores( + [$jushuitanStore, $missingStore], + '2026-10-23', + ); + $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN, $matches[$jushuitanStore->id]['data_source']); + $this->assertSame(JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE, $matches[$missingStore->id]['data_source']); + $this->assertSame(14000.0, $matches[$missingStore->id]['balance']); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'fund-management', + 'page' => 'withdrawal-management', + 'balance_date' => '2026-10-23', + ])) + ->assertOk() + ->assertSeeText('财务账单补录 1 条') + ->assertSee('withdrawal-balance-source is-finance', false) + ->assertSee( + 'title="数据来源:财务系统;店铺:天猫-财务账单自动补录店;余额日期:2026-10-23"', + false, + ); + } + + public function test_jushuitan_import_preserves_finance_fallback_rows(): void + { + $financeStore = $this->store('天猫', '财务补录保留店'); + $jushuitanStore = $this->store('天猫', '聚水潭刷新店'); + $this->insertSnapshot($financeStore->erp_store_name, 8800, 'finance'); + $file = $this->jushuitanJson([$jushuitanStore->erp_store_name]); + + $result = $this->app->make(JushuitanShopBalanceImporter::class) + ->import($file, '2026-10-23', 'taobao'); + + $this->assertSame(0, $result['replaced']); + $this->assertSame(1, DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '2026-10-23') + ->where('source_scope', 'taobao') + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE) + ->count()); + $this->assertSame(1, DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '2026-10-23') + ->where('source_scope', 'taobao') + ->where('data_source', JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN) + ->count()); + } + + private function store(string $platform, string $name): StoreReceivingAccount + { + return StoreReceivingAccount::query()->create([ + 'company_id' => $this->company->id, + 'store_platform' => $platform, + 'erp_store_name' => $platform . '-' . $name, + 'shop_name' => $platform . '-' . $name, + 'store_entity_name' => '余额回填测试主体', + ]); + } + + private function insertSnapshot(string $storeName, float $balance, string $dataSource): void + { + $normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName); + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => '2026-10-23', + 'source_scope' => str_starts_with($storeName, '京东') ? 'jd-wallet' : 'taobao', + 'data_source' => $dataSource, + 'source_platform' => JushuitanShopBalanceRepository::sourcePlatform($storeName), + 'store_name' => $storeName, + 'normalized_store_name' => $normalized, + 'platform_account_id' => 'fixture-account', + 'balance' => $balance, + 'source_row_key' => hash('sha256', $dataSource . '|' . $storeName), + 'source_file_name' => 'fixture-' . $dataSource . '.json', + 'source_sha256' => hash('sha256', $dataSource . '|' . $storeName . '|sha'), + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** @param list $storeNames */ + private function jushuitanJson(array $storeNames): string + { + $path = sys_get_temp_dir() . '/jst-finance-fallback-' . uniqid('', true) . '.json'; + $this->temporaryFiles[] = $path; + file_put_contents($path, json_encode([ + 'fldHeads' => [ + ['title' => '店铺', 'fld' => '店铺'], + ['title' => '平台实际期末余额', 'fld' => '平台实际期末余额'], + ], + 'dataList' => array_map( + static fn (string $storeName): array => [ + '店铺' => $storeName, + '平台实际期末余额' => 5000, + ], + $storeNames, + ), + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + + return $path; + } + + private function insertTaobaoBillRow( + string $storeName, + string $account, + string $occurredAt, + float $balance, + ): void { + DB::connection('business_mirror')->table('taobao_alipay_bill_rows')->insert([ + 'record_code' => 'record-' . uniqid('', true), + 'store_name' => $storeName, + 'alipay_account' => $account, + 'occurred_at' => $occurredAt, + 'balance_amount' => $balance, + ]); + } + + /** @return array */ + private function financeRow(string $storeName, float $balance): array + { + return [ + 'source_scope' => 'taobao', + 'source_platform' => '天猫', + 'store_name' => $storeName, + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName), + 'platform_account_id' => '20880001', + 'balance' => $balance, + 'occurred_at' => '2026-10-23 20:00:00', + 'source_row_key' => hash('sha256', 'finance|' . $storeName), + 'source_file_name' => '财务系统/店铺收款账户账单/淘系支付宝', + ]; + } +} diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index f8d406b5..4908541e 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -200,7 +200,7 @@ class ManagementCrudTest extends FeatureTestCase ->assertSee('aria-label="提现管理筛选"', false) ->assertSee('name="store_entity"', false) ->assertSee('value="提现测试主体"', false) - ->assertSee('title="聚水潭店铺:天猫-提现测试店铺;余额日期:2026-09-12"', false) + ->assertSee('title="数据来源:聚水潭;店铺:天猫-提现测试店铺;余额日期:2026-09-12"', false) ->assertSeeInOrder(['提现测试ERP店', '未接入测试ERP店']) ->assertDontSee('店铺名称', false) ->assertDontSee('银行卡账号(后四位)', false) diff --git a/tests/Feature/Business/WithdrawalManagementImportExportTest.php b/tests/Feature/Business/WithdrawalManagementImportExportTest.php index 7f1b9b58..a889ac42 100644 --- a/tests/Feature/Business/WithdrawalManagementImportExportTest.php +++ b/tests/Feature/Business/WithdrawalManagementImportExportTest.php @@ -159,11 +159,12 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase now()->subDays(3)->format('n月j日') . '店铺余额', now()->subDays(2)->format('n月j日') . '店铺余额', now()->subDay()->format('n月j日') . '店铺余额', + '余额来源', '余额预警', '预留标准', '提现金额', '是否达标', - ], $sheet->rangeToArray('A1:N1')[0]); + ], $sheet->rangeToArray('A1:O1')[0]); $this->assertSame(2, $sheet->getHighestDataRow()); $this->assertSame('导出目标店', $sheet->getCell('B2')->getValue()); $this->assertSame('00001234', $sheet->getCell('C2')->getValue()); @@ -560,8 +561,8 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase $values = []; for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) { $values[(string) $sheet->getCell("B{$row}")->getValue()] = [ - $sheet->getCell("M{$row}")->getValue(), $sheet->getCell("N{$row}")->getValue(), + $sheet->getCell("O{$row}")->getValue(), ]; } $this->assertSame([5000.0, '是'], $values['达标测试店']);