CaiWuXiTong/app/Business/WithdrawalBalanceAlertSynchronizer.php
92451 1cb04adc7b feat: 店铺台账接入每日余额、预留标准与预警
完成店铺台账每日余额来源、日期筛选、预留标准和连续3天预警。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-27 18:13:00 +08:00

244 lines
8.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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, ?string $sourceScope = null): 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,
null,
$sourceScope,
);
$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 !== ''));
}
}