feat: 提现管理连续3天余额预警并生成待办 #519

Merged
yutu merged 2 commits from feature/92451-withdrawal-three-day-alert into main 2026-10-24 19:50:40 +08:00
11 changed files with 885 additions and 118 deletions

View File

@ -0,0 +1,238 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use App\Models\Business\Todo;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
class WithdrawalBalanceAlertSynchronizer
{
public const CATEGORY = '提现余额预警';
public const TODO_STATUS = '待处理';
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* Evaluate the latest balance date for a company and persist one open todo
* per store that has exceeded its reserve standard without any change for
* three consecutive calendar days.
*
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
*/
public function syncLatest(int $companyId): array
{
$latest = $this->shopBalances->latestBalanceDate(now());
if ($latest === null) {
return [
'balance_date' => '',
'dates' => [],
'alerts' => [],
'created' => 0,
'updated' => 0,
];
}
return $this->syncForDate($companyId, $latest);
}
/**
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
*/
public function syncForDate(int $companyId, string $balanceDate): array
{
$dates = self::datesEndingAt($balanceDate);
$stores = StoreReceivingAccount::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
->orderBy('id')
->get();
if ($stores->isEmpty()) {
return [
'balance_date' => $balanceDate,
'dates' => $dates,
'alerts' => [],
'created' => 0,
'updated' => 0,
];
}
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($stores, $dates);
$reserveStandards = $stores->mapWithKeys(
static fn (StoreReceivingAccount $store): array => [
(int) $store->id => WithdrawalReserveStandard::amountFor(
$store->store_platform,
$store->erp_store_name,
),
],
)->all();
$alerts = self::detect($stores, $balancesByDate, $reserveStandards, $dates);
[$created, $updated] = DB::transaction(function () use ($companyId, $stores, &$alerts): array {
$created = 0;
$updated = 0;
foreach ($stores as $store) {
$alert = $alerts[(int) $store->id] ?? null;
if ($alert === null) {
continue;
}
$title = $this->todoTitle($store);
$description = $this->description($store, $alert);
$todo = Todo::withoutGlobalScopes()
->where('company_id', $companyId)
->where('category', self::CATEGORY)
->where('title', $title)
->whereNull('deleted_at')
->whereNotIn('status', ['已完成', '已关闭'])
->orderByDesc('id')
->lockForUpdate()
->first();
if ($todo === null) {
$todo = Todo::withoutGlobalScopes()->create([
'company_id' => $companyId,
'title' => $title,
'category' => self::CATEGORY,
'priority' => '高',
'status' => self::TODO_STATUS,
'description' => $description,
]);
$created++;
} else {
$attributes = [
'priority' => '高',
'description' => $description,
];
if ($todo->status !== self::TODO_STATUS) {
$attributes['status'] = self::TODO_STATUS;
}
if ($todo->fill($attributes)->isDirty()) {
$todo->save();
$updated++;
}
}
$alerts[(int) $store->id]['todo_id'] = (int) $todo->id;
}
return [$created, $updated];
});
return [
'balance_date' => $balanceDate,
'dates' => $dates,
'alerts' => $alerts,
'created' => $created,
'updated' => $updated,
];
}
/**
* @param iterable<StoreReceivingAccount> $stores
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
* @param array<int,int|null> $reserveStandards
* @param list<string> $dates
* @return array<int,array{balance:float,start_date:string,end_date:string,message:string}>
*/
public static function detect(
iterable $stores,
array $balancesByDate,
array $reserveStandards,
array $dates,
): array {
if (count($dates) !== 3) {
return [];
}
$alerts = [];
foreach ($stores as $store) {
$reserve = $reserveStandards[(int) $store->id] ?? null;
if ($reserve === null) {
continue;
}
$balanceCents = null;
$unchanged = true;
foreach ($dates as $date) {
$match = $balancesByDate[(int) $store->id][$date]
?? ['status' => 'not_ready', 'balance' => null];
if (($match['status'] ?? '') !== 'matched' || ($match['balance'] ?? null) === null) {
$unchanged = false;
break;
}
$cents = (int) round((float) $match['balance'] * 100);
if ($balanceCents === null) {
$balanceCents = $cents;
} elseif ($balanceCents !== $cents) {
$unchanged = false;
break;
}
}
if (! $unchanged || $balanceCents === null || $balanceCents <= $reserve * 100) {
continue;
}
$balance = $balanceCents / 100;
$alerts[(int) $store->id] = [
'balance' => $balance,
'start_date' => $dates[0],
'end_date' => $dates[2],
'message' => sprintf(
'余额连续 3 天保持 %s 元,高于预留标准 %s 元。',
number_format($balance, 2),
number_format($reserve, 2),
),
];
}
return $alerts;
}
/** @return list<string> */
public static function datesEndingAt(string $balanceDate): array
{
$end = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
if (! $end instanceof CarbonImmutable || $end->format('Y-m-d') !== $balanceDate) {
return [];
}
return [
$end->subDays(2)->toDateString(),
$end->subDay()->toDateString(),
$end->toDateString(),
];
}
private function todoTitle(StoreReceivingAccount $store): string
{
return mb_substr(
'提现余额连续3天未变动|' . (string) $store->store_platform . '|' . (string) $store->erp_store_name,
0,
255,
);
}
/** @param array{balance:float,start_date:string,end_date:string,message:string} $alert */
private function description(StoreReceivingAccount $store, array $alert): string
{
return implode("\n", array_filter([
$alert['message'],
'统计期间:' . $alert['start_date'] . ' 至 ' . $alert['end_date'],
'店铺平台:' . ($store->store_platform ?: '未配置'),
'ERP店铺名称:' . ($store->erp_store_name ?: '未配置'),
'店铺主体:' . ($store->store_entity_name ?: '未配置'),
'平台店铺ID:' . ($store->store_id ?: '未配置'),
'处理建议:核实店铺余额是否真实停滞,并检查是否应执行提现。',
], static fn (string $line): bool => $line !== ''));
}
}

View File

@ -18,7 +18,10 @@ class WithdrawalManagementReport
'withdrawal_path',
'withdrawal_account',
'bank_card',
'balance_two_days_ago',
'balance_yesterday',
'balance',
'balance_alert',
'reserve_standard',
'withdrawal_amount',
'compliance',
@ -34,9 +37,12 @@ class WithdrawalManagementReport
* 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}>>,
* 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},
* balanceColumnLabel:string,balanceDate:string,balanceDates:list<string>,sort:array{field:string,direction:string}
* balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list<string>,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
@ -71,7 +77,17 @@ class WithdrawalManagementReport
? CarbonImmutable::parse($balanceDate)
: $today;
$rows = $query->orderByDesc('id')->get();
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($rows, $historyDates);
$balances = array_map(
static fn (array $dates): array => $dates[$balanceDate] ?? [
'status' => 'not_ready',
'balance' => null,
'source_store_name' => null,
'source_platform' => null,
],
$balancesByDate,
);
$balanceCounts = collect($balances)->countBy(
static fn (array $match): string => $match['status'],
);
@ -90,6 +106,19 @@ class WithdrawalManagementReport
),
],
)->all();
$balanceDays = collect($historyDates)->map(
static fn (string $date, int $index): array => [
'key' => $index === 0 ? 'balance_two_days_ago' : ($index === 1 ? 'balance_yesterday' : 'balance'),
'date' => $date,
'label' => CarbonImmutable::parse($date)->format('n月j日') . '店铺余额',
],
)->values()->all();
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
$rows,
$balancesByDate,
$reserveStandards,
$historyDates,
);
$withdrawalAmounts = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
@ -106,7 +135,16 @@ class WithdrawalManagementReport
)->all();
$rows = $sort['field'] === ''
? $this->sortByBalanceStatus($rows, $balances)
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
: $this->sortRows(
$rows,
$sort,
$balancesByDate,
$balanceDays,
$alerts,
$reserveStandards,
$withdrawalAmounts,
$compliance,
);
return [
'filters' => $filters,
@ -115,6 +153,9 @@ class WithdrawalManagementReport
'storeEntities' => $storeEntities,
'rows' => $rows,
'balances' => $balances,
'balancesByDate' => $balancesByDate,
'balanceDays' => $balanceDays,
'alerts' => $alerts,
'reserveStandards' => $reserveStandards,
'withdrawalAmounts' => $withdrawalAmounts,
'compliance' => $compliance,
@ -127,6 +168,7 @@ class WithdrawalManagementReport
],
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'latestBalanceDate' => $latestBalanceDate,
'balanceDates' => $this->shopBalances->availableDates($today),
'sort' => $sort,
];
@ -209,17 +251,35 @@ class WithdrawalManagementReport
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array{field:string,direction:string} $sort
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?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
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
* @return Collection<int,StoreReceivingAccount>
*/
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): Collection
{
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance): int {
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
private function sortRows(
Collection $rows,
array $sort,
array $balancesByDate,
array $balanceDays,
array $alerts,
array $reserveStandards,
array $withdrawalAmounts,
array $compliance,
): Collection {
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use (
$sort,
$balancesByDate,
$balanceDays,
$alerts,
$reserveStandards,
$withdrawalAmounts,
$compliance,
): int {
$leftValue = $this->sortValue($left, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
$rightValue = $this->sortValue($right, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
return $comparison !== 0
@ -229,13 +289,38 @@ class WithdrawalManagementReport
}
/**
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?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
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
*/
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
{
private function sortValue(
StoreReceivingAccount $row,
string $field,
array $balancesByDate,
array $balanceDays,
array $alerts,
array $reserveStandards,
array $withdrawalAmounts,
array $compliance,
): mixed {
$balanceFor = static function (string $dayKey) use ($row, $balancesByDate, $balanceDays): ?float {
$date = null;
foreach ($balanceDays as $day) {
if ($day['key'] === $dayKey) {
$date = $day['date'];
break;
}
}
$match = $date === null
? null
: ($balancesByDate[(int) $row->id][$date] ?? null);
return ($match['status'] ?? null) === 'matched' ? (float) $match['balance'] : null;
};
return match ($field) {
'platform' => $row->store_platform,
'erp_store_name' => $row->erp_store_name,
@ -244,9 +329,10 @@ class WithdrawalManagementReport
'withdrawal_path' => $row->withdrawal_path,
'withdrawal_account' => $row->withdrawal_account_name,
'bank_card' => $row->bank_card_last_four,
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
? $balances[(int) $row->id]['balance']
: null,
'balance_two_days_ago' => $balanceFor('balance_two_days_ago'),
'balance_yesterday' => $balanceFor('balance_yesterday'),
'balance' => $balanceFor('balance'),
'balance_alert' => isset($alerts[(int) $row->id]) ? 1 : 0,
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
'compliance' => $compliance[(int) $row->id] ?? null,

View File

@ -40,6 +40,9 @@ 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}>>,
* 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>,
* balanceColumnLabel:string,balanceDate:string
* } $report
@ -271,19 +274,28 @@ class WithdrawalManagementSpreadsheet
private function workbook(array $report): Spreadsheet
{
$headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
$balanceHeaders = array_map(
static fn (array $day): string => (string) $day['label'],
$report['balanceDays'],
);
$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, 18, 18, 14]);
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 18, 18, 18, 14]);
$rowNumber = 2;
foreach ($report['rows'] as $row) {
$match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
$reserve = $report['reserveStandards'][(int) $row->id] ?? null;
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
$compliance = $report['compliance'][(int) $row->id] ?? null;
$alert = $report['alerts'][(int) $row->id] ?? null;
$hasCompleteAlertSeries = collect($report['balanceDays'])->every(
fn (array $day): bool => (
$report['balancesByDate'][(int) $row->id][(string) $day['date']]['status'] ?? ''
) === 'matched',
);
$textValues = [
1 => (string) ($row->store_platform ?? ''),
2 => (string) ($row->erp_store_name ?? ''),
@ -297,22 +309,33 @@ class WithdrawalManagementSpreadsheet
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
}
if ($match['status'] === 'matched') {
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
} else {
$sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
foreach ($report['balanceDays'] as $dayIndex => $day) {
$column = 8 + $dayIndex;
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
?? ['status' => 'not_ready', 'balance' => null];
if ($match['status'] === 'matched') {
$sheet->setCellValue([$column, $rowNumber], (float) $match['balance']);
} else {
$sheet->setCellValueExplicit([$column, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
}
}
$sheet->setCellValueExplicit(
[11, $rowNumber],
$alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'),
DataType::TYPE_STRING,
);
if ($reserve === null) {
$sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
$sheet->setCellValueExplicit([12, $rowNumber], '待配置', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([9, $rowNumber], $reserve);
$sheet->setCellValue([12, $rowNumber], $reserve);
}
if ($withdrawalAmount === null) {
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
$sheet->setCellValueExplicit([13, $rowNumber], '待接入', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
$sheet->setCellValue([13, $rowNumber], $withdrawalAmount);
}
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
$sheet->setCellValueExplicit([14, $rowNumber], match ($compliance) {
'yes' => '是',
'no' => '否',
default => '待核验',
@ -322,7 +345,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('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('L2:M' . ($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

@ -2,6 +2,8 @@
namespace App\Console\Commands;
use App\Business\WithdrawalBalanceAlertSynchronizer;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use Throwable;
@ -16,7 +18,10 @@ class ImportJushuitanShopBalances extends Command
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
public function handle(JushuitanShopBalanceImporter $importer): int
public function handle(
JushuitanShopBalanceImporter $importer,
WithdrawalBalanceAlertSynchronizer $alertSynchronizer,
): int
{
try {
$result = $importer->import(
@ -26,6 +31,7 @@ class ImportJushuitanShopBalances extends Command
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
@ -39,6 +45,34 @@ class ImportJushuitanShopBalances extends Command
$result['replaced'],
));
if (! $result['dry_run']) {
$companyIds = StoreReceivingAccount::withoutGlobalScopes()
->whereNull('deleted_at')
->distinct()
->pluck('company_id')
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
foreach ($companyIds as $companyId) {
try {
$alerts = $alertSynchronizer->syncLatest($companyId);
if ($alerts['created'] > 0 || $alerts['updated'] > 0) {
$this->info(sprintf(
'余额预警待办:公司 %d 新增 %d 条、更新 %d 条。',
$companyId,
$alerts['created'],
$alerts['updated'],
));
}
} catch (Throwable $exception) {
report($exception);
$this->warn("余额预警待办生成失败(公司 {$companyId}),余额快照已保留。");
}
}
}
return self::SUCCESS;
}
}

View File

@ -24,6 +24,7 @@ use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Models\Business\InventoryWarehouseNameMapping;
use App\Models\Business\Todo;
use App\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
@ -4136,6 +4137,7 @@ class Pages extends Controller
'shipping-data' => $this->contentForShippingData(),
'return-pickup-management' => $this->contentForReturnPickupManagement(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'todos' => $this->contentForTodos(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
'ledger' => app(\Modules\EcommerceFinance\View\FinanceLedgerPageData::class)->page($tab),
@ -5111,6 +5113,9 @@ class Pages extends Controller
'withdrawalPaths' => $report['paths'],
'withdrawalStoreEntities' => $report['storeEntities'],
'withdrawalBalances' => $report['balances'],
'withdrawalBalancesByDate' => $report['balancesByDate'],
'withdrawalBalanceDays' => $report['balanceDays'],
'withdrawalAlerts' => $report['alerts'],
'withdrawalReserveStandards' => $report['reserveStandards'],
'withdrawalAmounts' => $report['withdrawalAmounts'],
'withdrawalCompliance' => $report['compliance'],
@ -5122,6 +5127,29 @@ class Pages extends Controller
];
}
/**
* 待办事项:优先展示业务系统生成的待办;为空时继续使用目录页的字段说明。
*/
protected function contentForTodos(): ?array
{
$query = Todo::query()->orderByRaw(
"CASE WHEN status IN ('已完成', '已关闭') THEN 1 ELSE 0 END"
)->orderByDesc('created_at')->orderByDesc('id');
if (! $query->exists()) {
return null;
}
$page = max((int) request()->query('page', 1), 1);
$items = $query->paginate(50, ['*'], 'page', $page)->withQueryString();
return [
'view' => 'business.pages.todos',
'items' => $items,
'title' => '待办事项',
];
}
/**
* 快递附加费报价维护。
*

View File

@ -26,26 +26,49 @@ class JushuitanShopBalanceRepository
*/
public function matchesForStores(iterable $stores, string $balanceDate): array
{
$stores = is_array($stores) ? $stores : iterator_to_array($stores);
$matches = $this->matchesForStoresOnDates($stores, [$balanceDate]);
return array_map(
static fn (array $dates): array => $dates[$balanceDate],
$matches,
);
}
/**
* @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}>>
*/
public function matchesForStoresOnDates(iterable $stores, array $balanceDates): array
{
$stores = is_array($stores) ? array_values($stores) : iterator_to_array($stores, false);
$balanceDates = array_values(array_unique(array_filter(
$balanceDates,
static fn (mixed $date): bool => is_string($date) && $date !== '',
)));
$result = [];
foreach ($stores as $store) {
$result[(int) $store->id] = $this->emptyMatch('not_ready');
$result[(int) $store->id] = array_fill_keys(
$balanceDates,
$this->emptyMatch('not_ready'),
);
}
if (! $this->hasTable()) {
if ($balanceDates === [] || ! $this->hasTable()) {
return $result;
}
$sourceRows = $this->connection->table(self::TABLE)
->where('balance_date', $balanceDate)
$sourceRowsByDate = $this->connection->table(self::TABLE)
->whereIn('balance_date', $balanceDates)
->orderBy('id')
->get([
'id', 'source_platform', 'store_name', 'normalized_store_name',
'id', 'balance_date', 'source_platform', 'store_name', 'normalized_store_name',
'platform_account_id', 'balance',
]);
])
->groupBy(static fn (object $row): string => substr((string) $row->balance_date, 0, 10));
if ($sourceRows->isEmpty()) {
if ($sourceRowsByDate->isEmpty()) {
return $result;
}
@ -55,44 +78,46 @@ class JushuitanShopBalanceRepository
self::normalizeStoreName((string) ($store->erp_store_name ?? '')),
])));
$matches = $sourceRows->filter(
static fn (object $row): bool => in_array((string) $row->normalized_store_name, $names, true),
)->values();
if ($matches->isEmpty()) {
$result[(int) $store->id] = $this->emptyMatch('unmatched');
continue;
}
$targetPlatform = self::platformKey((string) ($store->store_platform ?? ''));
if ($targetPlatform !== null) {
$matches = $matches->filter(
static function (object $row) use ($targetPlatform): bool {
$sourcePlatform = self::platformKey((string) ($row->source_platform ?? ''))
?? self::platformKey((string) self::sourcePlatform((string) ($row->store_name ?? '')));
return $sourcePlatform !== null && $sourcePlatform === $targetPlatform;
},
foreach ($balanceDates as $balanceDate) {
$matches = ($sourceRowsByDate[$balanceDate] ?? collect())->filter(
static fn (object $row): bool => in_array((string) $row->normalized_store_name, $names, true),
)->values();
if ($matches->isEmpty()) {
$result[(int) $store->id] = $this->emptyMatch('unmatched');
$result[(int) $store->id][$balanceDate] = $this->emptyMatch('unmatched');
continue;
}
}
if ($matches->count() !== 1) {
$result[(int) $store->id] = $this->emptyMatch('ambiguous');
continue;
}
$targetPlatform = self::platformKey((string) ($store->store_platform ?? ''));
if ($targetPlatform !== null) {
$matches = $matches->filter(
static function (object $row) use ($targetPlatform): bool {
$sourcePlatform = self::platformKey((string) ($row->source_platform ?? ''))
?? self::platformKey((string) self::sourcePlatform((string) ($row->store_name ?? '')));
$match = $matches->first();
$result[(int) $store->id] = [
'status' => 'matched',
'balance' => (float) $match->balance,
'source_store_name' => (string) $match->store_name,
'source_platform' => $match->source_platform === null ? null : (string) $match->source_platform,
];
return $sourcePlatform !== null && $sourcePlatform === $targetPlatform;
},
)->values();
if ($matches->isEmpty()) {
$result[(int) $store->id][$balanceDate] = $this->emptyMatch('unmatched');
continue;
}
}
if ($matches->count() !== 1) {
$result[(int) $store->id][$balanceDate] = $this->emptyMatch('ambiguous');
continue;
}
$match = $matches->first();
$result[(int) $store->id][$balanceDate] = [
'status' => 'matched',
'balance' => (float) $match->balance,
'source_store_name' => (string) $match->store_name,
'source_platform' => $match->source_platform === null ? null : (string) $match->source_platform,
];
}
}
return $result;

View File

@ -25,11 +25,21 @@
['key' => 'withdrawal_path', 'label' => '提现路径', 'numeric' => false, 'title' => null],
['key' => 'withdrawal_account', 'label' => '提现账户名称', 'numeric' => false, 'title' => null],
['key' => 'bank_card', 'label' => '银行卡号', 'numeric' => false, 'title' => null],
['key' => 'balance', 'label' => $withdrawalBalanceColumnLabel, 'numeric' => true, 'title' => '余额数据日期:' . $withdrawalBalanceDate],
...array_map(
static fn (array $day): array => [
'key' => $day['key'],
'label' => $day['label'],
'numeric' => true,
'title' => '余额数据日期:' . $day['date'],
],
$withdrawalBalanceDays,
),
['key' => 'balance_alert', 'label' => '余额预警', 'numeric' => false, 'title' => '连续3个自然日余额一致且高于预留标准'],
['key' => 'reserve_standard', 'label' => '预留标准', 'numeric' => true, 'title' => null],
['key' => 'withdrawal_amount', 'label' => '提现金额', 'numeric' => true, 'title' => null],
['key' => 'compliance', 'label' => '是否达标', 'numeric' => false, 'title' => null],
];
$withdrawalAlertCount = count($withdrawalAlerts);
@endphp
<section class="biz-card mt-6 withdrawal-management" aria-label="提现配置列表">
@ -45,6 +55,9 @@
@if ($withdrawalBalanceSummary['ambiguous'] > 0)
<span class="biz-badge biz-badge-no">多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条</span>
@endif
@if ($withdrawalAlertCount > 0)
<span class="biz-badge withdrawal-alert-summary">连续3天未变动 {{ number_format($withdrawalAlertCount) }} 家</span>
@endif
</div>
</div>
<div class="withdrawal-management__actions" aria-label="提现管理导入导出">
@ -127,7 +140,7 @@
data-finance-table-name="withdrawal-management-v4"
data-finance-table-native
data-withdrawal-table
data-column-storage-key="caiwu.withdrawal.columns.v1.{{ auth()->id() ?? 'guest' }}"
data-column-storage-key="caiwu.withdrawal.columns.v2.{{ auth()->id() ?? 'guest' }}"
>
<thead>
<tr>
@ -167,20 +180,32 @@
? '**** ' . $row->bank_card_last_four
: '未配置';
$balanceMatch = $withdrawalBalances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
$balanceText = match ($balanceMatch['status']) {
'matched' => number_format((float) $balanceMatch['balance'], 2),
'ambiguous' => '多条匹配',
'unmatched' => '未匹配',
default => '待同步',
};
$balanceTitle = $balanceMatch['status'] === 'matched'
? '聚水潭店铺:' . $balanceMatch['source_store_name'] . ';余额日期:' . $withdrawalBalanceDate
: '余额日期:' . $withdrawalBalanceDate;
$balanceCells = [];
foreach ($withdrawalBalanceDays as $day) {
$dayMatch = $withdrawalBalancesByDate[(int) $row->id][$day['date']]
?? ['status' => 'not_ready', 'balance' => null];
$balanceCells[$day['key']] = [
'matched' => $dayMatch['status'] === 'matched',
'text' => match ($dayMatch['status']) {
'matched' => number_format((float) $dayMatch['balance'], 2),
'ambiguous' => '多条匹配',
'unmatched' => '未匹配',
default => '待同步',
},
'title' => $dayMatch['status'] === 'matched'
? '聚水潭店铺:' . $dayMatch['source_store_name'] . ';余额日期:' . $day['date']
: '余额日期:' . $day['date'],
];
}
$reserveStandard = $withdrawalReserveStandards[(int) $row->id] ?? null;
$withdrawalAmount = $withdrawalAmounts[(int) $row->id] ?? null;
$compliance = $withdrawalCompliance[(int) $row->id] ?? null;
$alert = $withdrawalAlerts[(int) $row->id] ?? null;
$hasCompleteAlertSeries = collect($balanceCells)->every(
static fn (array $cell): bool => $cell['matched'],
);
@endphp
<tr>
<tr @class(['is-withdrawal-warning' => $alert !== null])>
<td data-withdrawal-column="platform">{{ $row->store_platform ?: '未配置' }}</td>
<td data-withdrawal-column="erp_store_name">{{ $row->erp_store_name ?: '-' }}</td>
<td data-withdrawal-column="store_id">{{ $row->store_id ?: '-' }}</td>
@ -188,7 +213,25 @@
<td data-withdrawal-column="withdrawal_path">{{ $row->withdrawal_path ?: '未配置' }}</td>
<td data-withdrawal-column="withdrawal_account">{{ $row->withdrawal_account_name ?: '未配置' }}</td>
<td data-withdrawal-column="bank_card">{{ $bankCard }}</td>
<td class="num" data-withdrawal-column="balance" title="{{ $balanceTitle }}">{{ $balanceText }}</td>
@foreach ($withdrawalBalanceDays as $day)
<td class="num" data-withdrawal-column="{{ $day['key'] }}" title="{{ $balanceCells[$day['key']]['title'] }}">{{ $balanceCells[$day['key']]['text'] }}</td>
@endforeach
<td data-withdrawal-column="balance_alert">
@if ($alert !== null)
<a
class="withdrawal-alert-badge"
href="{{ route('business.pages.show', ['section' => 'other', 'page' => 'todos']) }}"
title="{{ $alert['message'] }}"
>
<span class="material-icons-outlined" aria-hidden="true">warning_amber</span>
<span>连续3天未变动</span>
</a>
@elseif (! $hasCompleteAlertSeries)
<span class="withdrawal-alert-none">数据不足</span>
@else
<span class="withdrawal-alert-none">未触发</span>
@endif
</td>
<td class="num" data-withdrawal-column="reserve_standard">{{ $reserveStandard === null ? '待配置' : number_format($reserveStandard, 2) }}</td>
<td class="num" data-withdrawal-column="withdrawal_amount">{{ $withdrawalAmount === null ? '待接入' : number_format($withdrawalAmount, 2) }}</td>
<td data-withdrawal-column="compliance">
@ -203,7 +246,7 @@
</tr>
@empty
<tr>
<td colspan="11">
<td colspan="{{ count($withdrawalColumns) }}">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>暂无店铺收款账户,请先在店铺收款账户中维护。</div>
@ -359,6 +402,12 @@
.withdrawal-management .biz-btn-primary:hover { background: #6f5429; }
.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-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; }
.withdrawal-alert-badge .material-icons-outlined { font-size:1rem; }
.withdrawal-alert-none { color:#595959; font-size:.75rem; }
.withdrawal-management__actions { display:flex; flex:0 0 auto; align-items:center; gap:.5rem; margin-left:auto; }
.withdrawal-management__actions .biz-btn { min-width:5.5rem; justify-content:center; }
.withdrawal-table-head { display:flex; min-width:max-content; align-items:center; justify-content:space-between; gap:.35rem; }

View File

@ -1,36 +1,82 @@
<div class="business-page-header">
<span class="business-page-title">{{ $title ?? '待办事项' }}</span>
</div>
<div class="business-table-container">
<table class="business-table">
<thead>
<tr>
<th>序号</th>
<section class="biz-card mt-6 todo-list" aria-label="待办事项">
<div class="biz-card-head">
<div>
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
<span>待办清单</span>
</div>
<p class="todo-list__description">集中展示系统预警和人工待办,保持事项从触发到处理的完整记录。</p>
</div>
<span class="biz-badge">{{ number_format($items->total()) }} 条</span>
</div>
<div class="biz-table-wrap">
<table class="biz-table" aria-label="待办事项表格">
<thead>
<tr>
<th>序号</th>
<th>标题</th>
<th>分类</th>
<th>优先级</th>
<th>状态</th>
<th>截止日期</th>
<th>描述</th>
</tr>
</thead>
<tbody>
@foreach($items as $item)
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $item->title }}</td>
<td><span class="business-badge business-badge-info">{{ $item->category }}</span></td>
<td><span class="business-badge business-badge-info">{{ $item->priority }}</span></td>
<td><span class="business-badge business-badge-info">{{ $item->status }}</span></td>
<td>{{ $item->due_date }}</td>
<td>{{ $item->description }}</td>
</tr>
@endforeach
@if($items->isEmpty())
<tr><td colspan=7 style="text-align:center; color:#9ca3af; padding:2rem;">暂无数据</td></tr>
@endif
</tbody>
</table>
</div>
</thead>
<tbody>
@forelse ($items as $item)
<tr>
<td>{{ $items->firstItem() + $loop->index }}</td>
<td>{{ $item->title }}</td>
<td><span class="biz-badge">{{ $item->category ?: '-' }}</span></td>
<td>
<span @class(['biz-badge', 'todo-list__priority' => $item->priority === '高'])>
{{ $item->priority ?: '-' }}
</span>
</td>
<td>
<span @class(['biz-badge', 'biz-badge-no' => in_array($item->status, ['已完成', '已关闭'], true)])>
{{ $item->status ?: '待处理' }}
</span>
</td>
<td>{{ $item->due_date ?: '-' }}</td>
<td class="todo-list__detail">{!! nl2br(e($item->description ?: '-')) !!}</td>
</tr>
@empty
<tr>
<td colspan="7">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>暂无待办事项。</div>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($items->hasPages())
<div class="biz-pagination">
<span>共 {{ number_format($items->total()) }} 条 · 第 {{ $items->currentPage() }} / {{ $items->lastPage() }} 页</span>
<span>
@if ($items->onFirstPage())
<span>‹ 上一页</span>
@else
<a href="{{ $items->previousPageUrl() }}">‹ 上一页</a>
@endif
@if ($items->hasMorePages())
<a href="{{ $items->nextPageUrl() }}">下一页 ›</a>
@else
<span>下一页 ›</span>
@endif
</span>
</div>
@endif
</section>
<style>
.todo-list__description { margin:.35rem 0 0; color:#595959; font-size:.75rem; line-height:1.5; }
.todo-list__priority { border-color:#d8b15d; background:#fff7df; color:#935f07; }
.todo-list__detail { min-width:22rem; max-width:42rem; white-space:normal; line-height:1.65; }
</style>

View File

@ -150,7 +150,10 @@ class ManagementCrudTest extends FeatureTestCase
->assertSeeText('TM-TEST-1001')
->assertSeeText('测试提现账户')
->assertSeeText('**** 4321')
->assertSeeText('9月13日店铺余额')
->assertSeeText('9月11日店铺余额')
->assertSeeText('9月12日店铺余额')
->assertSeeText('9月10日店铺余额')
->assertSeeText('余额预警')
->assertSeeText('19,895.01')
->assertSeeText('余额源 1 条')
->assertSeeText('已显示余额 1 条')
@ -186,7 +189,10 @@ class ManagementCrudTest extends FeatureTestCase
'data-withdrawal-column="withdrawal_path"',
'data-withdrawal-column="withdrawal_account"',
'data-withdrawal-column="bank_card"',
'data-withdrawal-column="balance_two_days_ago"',
'data-withdrawal-column="balance_yesterday"',
'data-withdrawal-column="balance"',
'data-withdrawal-column="balance_alert"',
'data-withdrawal-column="reserve_standard"',
'data-withdrawal-column="withdrawal_amount"',
'data-withdrawal-column="compliance"',

View File

@ -0,0 +1,219 @@
<?php
namespace Tests\Feature\Business;
use App\Business\WithdrawalBalanceAlertSynchronizer;
use App\Models\Auth\Permission;
use App\Models\Business\StoreReceivingAccount;
use App\Models\Business\Todo;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Tests\Feature\FeatureTestCase;
class WithdrawalBalanceAlertTest extends FeatureTestCase
{
/** @var list<string> */
private array $temporaryFiles = [];
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail(),
);
$this->withoutMiddleware(ThrottleRequests::class);
}
public function test_three_unchanged_balances_above_reserve_show_warning_and_create_one_todo(): void
{
Carbon::setTestNow('2026-10-24 10:00:00');
try {
$store = StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '京东-连续余额测试店',
'shop_name' => '京东-连续余额测试店',
'store_entity_name' => '连续余额测试主体',
'store_id' => 'JD-ALERT-001',
]);
foreach (['2026-10-21', '2026-10-22', '2026-10-23'] as $date) {
$this->insertBalanceSnapshot($date, '京东-连续余额测试店', 8000);
}
$sync = app(WithdrawalBalanceAlertSynchronizer::class);
$firstSync = $sync->syncForDate((int) $this->company->id, '2026-10-23');
$secondSync = $sync->syncForDate((int) $this->company->id, '2026-10-23');
$this->assertSame(1, $firstSync['created']);
$this->assertSame(0, $firstSync['updated']);
$this->assertSame(0, $secondSync['created']);
$this->assertSame(0, $secondSync['updated']);
$url = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'fund-management',
'page' => 'withdrawal-management',
'balance_date' => '2026-10-23',
'platform' => '京东',
]);
$this->loginAs()->get($url)
->assertOk()
->assertSeeText('10月21日店铺余额')
->assertSeeText('10月22日店铺余额')
->assertSeeText('10月23日店铺余额')
->assertSeeText('已显示余额 1 条')
->assertSeeText('连续3天未变动')
->assertSeeText('8,000.00');
$title = '提现余额连续3天未变动|京东|京东-连续余额测试店';
$todo = Todo::withoutGlobalScopes()->where('company_id', $this->company->id)->where('title', $title)->first();
$this->assertNotNull($todo);
$this->assertSame('提现余额预警', $todo->category);
$this->assertSame('高', $todo->priority);
$this->assertSame('待处理', $todo->status);
$this->assertStringContainsString('2026-10-21 至 2026-10-23', (string) $todo->description);
$this->assertStringContainsString((string) $store->store_entity_name, (string) $todo->description);
$this->get($url)->assertOk();
$this->assertSame(
1,
Todo::withoutGlobalScopes()
->where('company_id', $this->company->id)
->where('category', '提现余额预警')
->count(),
);
$this->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'other',
'page' => 'todos',
]))
->assertOk()
->assertSeeText($title)
->assertSeeText('提现余额预警')
->assertSeeText('连续余额测试主体');
} finally {
Carbon::setTestNow();
}
}
public function test_alert_requires_three_matched_days_an_exact_unchanged_value_and_a_strict_excess(): void
{
Carbon::setTestNow('2026-10-24 10:00:00');
try {
foreach (['等于预留标准店', '余额发生变动店', '缺少一天店'] as $name) {
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '京东-' . $name,
'shop_name' => '京东-' . $name,
'store_entity_name' => '预警边界测试主体',
]);
}
foreach (['2026-10-21' => 5000, '2026-10-22' => 5000, '2026-10-23' => 5000] as $date => $balance) {
$this->insertBalanceSnapshot($date, '京东-等于预留标准店', $balance);
}
foreach (['2026-10-21' => 8000, '2026-10-22' => 8000, '2026-10-23' => 7999.99] as $date => $balance) {
$this->insertBalanceSnapshot($date, '京东-余额发生变动店', $balance);
}
foreach (['2026-10-22' => 8000, '2026-10-23' => 8000] as $date => $balance) {
$this->insertBalanceSnapshot($date, '京东-缺少一天店', $balance);
}
$sync = app(WithdrawalBalanceAlertSynchronizer::class)->syncForDate(
(int) $this->company->id,
'2026-10-23',
);
$this->assertSame(0, $sync['created']);
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'fund-management',
'page' => 'withdrawal-management',
'balance_date' => '2026-10-23',
'platform' => '京东',
]))->assertOk()->assertSeeText('未触发')->assertSeeText('数据不足');
$this->assertSame(0, DB::table('business_todos')->count());
} finally {
Carbon::setTestNow();
}
}
public function test_successful_balance_import_runs_alert_generation_automatically(): void
{
Carbon::setTestNow('2026-10-24 10:00:00');
try {
StoreReceivingAccount::query()->create([
'company_id' => $this->company->id,
'store_platform' => '京东',
'erp_store_name' => '京东-导入预警测试店',
'shop_name' => '京东-导入预警测试店',
'store_entity_name' => '导入预警测试主体',
]);
$this->insertBalanceSnapshot('2026-10-21', '京东-导入预警测试店', 7000);
$this->insertBalanceSnapshot('2026-10-22', '京东-导入预警测试店', 7000);
$path = sys_get_temp_dir() . '/jst-balance-alert-' . uniqid('', true) . '.json';
$this->temporaryFiles[] = $path;
file_put_contents($path, json_encode([
'fldHeads' => [
['title' => '店铺', 'fld' => '店铺'],
['title' => '平台实际期末余额', 'fld' => '平台实际期末余额'],
],
'dataList' => [
['店铺' => '京东-导入预警测试店', '平台实际期末余额' => 7000],
],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->artisan('business:import-jushuitan-shop-balances', [
'path' => $path,
'--balance-date' => '2026-10-23',
'--scope' => 'jd-wallet',
])->assertExitCode(0);
$this->assertDatabaseHas('business_todos', [
'company_id' => $this->company->id,
'title' => '提现余额连续3天未变动|京东|京东-导入预警测试店',
'category' => '提现余额预警',
'status' => '待处理',
]);
} finally {
Carbon::setTestNow();
}
}
protected function tearDown(): void
{
foreach ($this->temporaryFiles as $path) {
if (is_file($path)) {
unlink($path);
}
}
parent::tearDown();
}
private function insertBalanceSnapshot(string $date, string $storeName, float $balance): void
{
DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([
'balance_date' => $date,
'source_scope' => 'jd-wallet',
'source_platform' => '京东',
'store_name' => $storeName,
'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName),
'platform_account_id' => 'fixture-' . $storeName,
'balance' => $balance,
'source_row_key' => hash('sha256', $date . '|' . $storeName),
'source_file_name' => 'fixture.xlsx',
'source_sha256' => hash('sha256', 'fixture-' . $date . '-' . $storeName),
'imported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@ -141,11 +141,14 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
$sheet = $spreadsheet->getActiveSheet();
$this->assertSame([
...self::HEADERS,
now()->format('n月j日') . '店铺余额',
now()->subDays(3)->format('n月j日') . '店铺余额',
now()->subDays(2)->format('n月j日') . '店铺余额',
now()->subDay()->format('n月j日') . '店铺余额',
'余额预警',
'预留标准',
'提现金额',
'是否达标',
], $sheet->rangeToArray('A1:K1')[0]);
], $sheet->rangeToArray('A1:N1')[0]);
$this->assertSame(2, $sheet->getHighestDataRow());
$this->assertSame('导出目标店', $sheet->getCell('B2')->getValue());
$this->assertSame('00001234', $sheet->getCell('C2')->getValue());
@ -368,7 +371,14 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
$mirror = Mockery::mock(JushuitanShopBalanceRepository::class);
$mirror->shouldReceive('latestBalanceDate')->andReturn('2026-09-23');
$mirror->shouldReceive('availableDates')->andReturn(['2026-09-23', '2026-09-22']);
$mirror->shouldReceive('matchesForStores')->andReturn($balances);
$historyDates = ['2026-09-21', '2026-09-22', '2026-09-23'];
$balancesByDate = [];
foreach (array_keys($balances) as $storeId) {
foreach ($historyDates as $date) {
$balancesByDate[$storeId][$date] = $balances[$storeId];
}
}
$mirror->shouldReceive('matchesForStoresOnDates')->andReturn($balancesByDate);
$mirror->shouldReceive('countForDate')->andReturn(6);
$this->app->instance(JushuitanShopBalanceRepository::class, $mirror);
@ -399,8 +409,8 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
$values = [];
for ($row = 2; $row <= $sheet->getHighestDataRow(); $row++) {
$values[(string) $sheet->getCell("B{$row}")->getValue()] = [
$sheet->getCell("J{$row}")->getValue(),
$sheet->getCell("K{$row}")->getValue(),
$sheet->getCell("M{$row}")->getValue(),
$sheet->getCell("N{$row}")->getValue(),
];
}
$this->assertSame([5000.0, '是'], $values['达标测试店']);
@ -482,9 +492,10 @@ class WithdrawalManagementImportExportTest extends FeatureTestCase
]));
$response->assertOk()
->assertSeeText('10月20日店铺余额')
->assertSeeText('10月14日店铺余额')
->assertSeeText('10月15日店铺余额')
->assertSeeText('12,345.67')
->assertDontSeeText('99,999.99')
->assertSeeText('99,999.99')
->assertSee('余额日期:2026-10-15', false);
$historyResponse->assertOk()