feat: 补录财务账单店铺余额并标记来源

聚水潭缺失店铺按财务账单补录;页面和导出标记数据来源。

PR #552
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
This commit is contained in:
92451 2026-10-25 17:06:19 +08:00 committed by yutu
parent 4cf90cd3a6
commit 0b69f30714
12 changed files with 1067 additions and 31 deletions

View File

@ -36,12 +36,12 @@ class WithdrawalManagementReport
* @return array{
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
* balanceDays:list<array{key:string,date:string,label:string}>,
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* 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<string>,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<int,StoreReceivingAccount> $rows
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}> $balances
* @return Collection<int,StoreReceivingAccount>
*/
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
@ -251,7 +256,7 @@ class WithdrawalManagementReport
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array{field:string,direction:string} $sort
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>> $balancesByDate
* @param list<array{key:string,date:string,label:string}> $balanceDays
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
* @param array<int,int|null> $reserveStandards

View File

@ -51,8 +51,8 @@ class WithdrawalManagementSpreadsheet
/**
* @param array{
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
* balanceDays:list<array{key:string,date:string,label:string}>,
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
@ -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('@');

View File

@ -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;
}
}

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): 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->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');
}
};

View File

@ -0,0 +1,289 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use RuntimeException;
/**
* 从财务「店铺收款账户账单」读取可用于补充店铺余额的累计账户余额。
*
* 当前只有淘系支付宝和京东钱包账单包含真实账户余额;拼多多、抖音
* 的账单行只有收支发生额,不能据此推导平台账户余额。
*/
class FinanceBillBalanceRepository
{
public const SCOPE_TAOBAO = 'taobao';
public const SCOPE_JD_WALLET = 'jd-wallet';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->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<array{
* source_scope:string,source_platform:string,store_name:string,normalized_store_name:string,
* platform_account_id:?string,balance:float,occurred_at:string,source_row_key:string,source_file_name:string
* }>
*/
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<int,object> */
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<int,object> */
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<string> $storeNames
* @param list<string>|null $generations
* @return Collection<int,object>
*/
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<int,object> $rows
* @return list<array{
* source_scope:string,source_platform:string,store_name:string,normalized_store_name:string,
* platform_account_id:?string,balance:float,occurred_at:string,source_row_key:string,source_file_name:string
* }>
*/
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;
}
}

View File

@ -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<object> $stores
* @return array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>
* @return array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>
*/
public function matchesForStores(iterable $stores, string $balanceDate): array
{
@ -37,9 +39,13 @@ class JushuitanShopBalanceRepository
/**
* @param iterable<object> $stores
* @param list<string> $balanceDates
* @return array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>>
* @return array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>
*/
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,
];
}

View File

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

View File

@ -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,

View File

@ -52,6 +52,9 @@
<div class="withdrawal-management__summary">
<span class="biz-badge">余额源 {{ number_format($withdrawalBalanceSummary['source']) }} 条</span>
<span class="biz-badge biz-badge-yes">已显示余额 {{ number_format($withdrawalBalanceSummary['matched']) }} 条</span>
@if (($withdrawalBalanceSummary['finance'] ?? 0) > 0)
<span class="biz-badge withdrawal-alert-summary">财务账单补录 {{ number_format($withdrawalBalanceSummary['finance']) }} 条</span>
@endif
@if ($withdrawalBalanceSummary['ambiguous'] > 0)
<span class="biz-badge biz-badge-no">多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条</span>
@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 @@
<td data-withdrawal-column="withdrawal_account">{{ $row->withdrawal_account_name ?: '未配置' }}</td>
<td data-withdrawal-column="bank_card">{{ $bankCard }}</td>
@foreach ($withdrawalBalanceDays as $day)
<td class="num" data-withdrawal-column="{{ $day['key'] }}" title="{{ $balanceCells[$day['key']]['title'] }}">{{ $balanceCells[$day['key']]['text'] }}</td>
<td class="num" data-withdrawal-column="{{ $day['key'] }}" title="{{ $balanceCells[$day['key']]['title'] }}">
<span class="withdrawal-balance-value">
<span>{{ $balanceCells[$day['key']]['text'] }}</span>
@if ($balanceCells[$day['key']]['source'])
<span @class([
'withdrawal-balance-source',
'is-finance' => $balanceCells[$day['key']]['source'] === '财务系统',
])>{{ $balanceCells[$day['key']]['source'] }}</span>
@endif
</span>
</td>
@endforeach
<td data-withdrawal-column="balance_alert">
@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; }

View File

@ -0,0 +1,326 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Support\Carbon;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Repositories\FinanceBillBalanceRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
class FinanceBillShopBalanceFallbackTest extends FeatureTestCase
{
/** @var list<string> */
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<string> $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<string,mixed> */
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' => '财务系统/店铺收款账户账单/淘系支付宝',
];
}
}

View File

@ -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('<th>店铺名称</th>', false)
->assertDontSee('<th>银行卡账号(后四位)</th>', false)

View File

@ -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['达标测试店']);