## 完成内容 - 提现管理展示所选余额日及前两天余额。 - 连续3个自然日余额一致且均严格高于预留标准时显示预警。 - 余额导入成功后自动生成或更新待处理事项,同一店铺不重复创建。 - 导出文件同步包含三天余额和预警状态。 ## 验证 - 新增预警测试:3 项通过,30 个断言。 - 提现管理导入导出测试:12 项通过,96 个断言。 - 管理页面回归:1 项通过,56 个断言。 - 余额仓库测试:11 项通过,46 个断言。 - 本地页面已验证:39 家店铺触发预警,待办事项生成 39 条。 ## 风险 - 无数据库结构迁移。 - 不改上游数据;仅在成功导入余额后读取共享镜像并写入待办。 Reviewed-on: #519 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
239 lines
8.3 KiB
PHP
239 lines
8.3 KiB
PHP
<?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 !== ''));
|
||
}
|
||
}
|