feat: 提现管理连续3天余额预警并生成待办 #519
238
app/Business/WithdrawalBalanceAlertSynchronizer.php
Normal file
238
app/Business/WithdrawalBalanceAlertSynchronizer.php
Normal 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 !== ''));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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('@');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => '待办事项',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递附加费报价维护。
|
||||
*
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"',
|
||||
|
||||
219
tests/Feature/Business/WithdrawalBalanceAlertTest.php
Normal file
219
tests/Feature/Business/WithdrawalBalanceAlertTest.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user