CaiWuXiTong/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php
92451 89bd1703c7 feat: 提现管理连续3天余额预警并生成待办 (#519)
## 完成内容
- 提现管理展示所选余额日及前两天余额。
- 连续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>
2026-10-24 19:50:40 +08:00

261 lines
8.9 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 Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
class JushuitanShopBalanceRepository
{
public const TABLE = 'jushuitan_shop_balance_snapshots';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param iterable<object> $stores
* @return array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>
*/
public function matchesForStores(iterable $stores, string $balanceDate): array
{
$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] = array_fill_keys(
$balanceDates,
$this->emptyMatch('not_ready'),
);
}
if ($balanceDates === [] || ! $this->hasTable()) {
return $result;
}
$sourceRowsByDate = $this->connection->table(self::TABLE)
->whereIn('balance_date', $balanceDates)
->orderBy('id')
->get([
'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 ($sourceRowsByDate->isEmpty()) {
return $result;
}
foreach ($stores as $store) {
$names = array_values(array_unique(array_filter([
self::normalizeStoreName((string) ($store->shop_name ?? '')),
self::normalizeStoreName((string) ($store->erp_store_name ?? '')),
])));
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][$balanceDate] = $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;
},
)->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;
}
public function countForDate(string $balanceDate): int
{
if (! $this->hasTable()) {
return 0;
}
return $this->connection->table(self::TABLE)
->where('balance_date', $balanceDate)
->count();
}
/**
* 取不晚于今天的最新余额日期;没有任何快照时返回 null。
*/
public function latestBalanceDate(?CarbonInterface $today = null): ?string
{
if (! $this->hasTable()) {
return null;
}
$today = ($today ?? now())->copy()->startOfDay();
$latest = $this->connection->table(self::TABLE)
->where('balance_date', '<=', $today->toDateString())
->max('balance_date');
return $latest === null ? null : substr((string) $latest, 0, 10);
}
/**
* 取最早余额日期,供页面日期筛选下限使用;没有快照时返回 null。
*/
public function earliestBalanceDate(): ?string
{
if (! $this->hasTable()) {
return null;
}
$earliest = $this->connection->table(self::TABLE)->min('balance_date');
return $earliest === null ? null : substr((string) $earliest, 0, 10);
}
/**
* 取所有有余额快照的日期(倒序),供页面日期下拉使用。
*
* @return list<string>
*/
public function availableDates(?CarbonInterface $today = null): array
{
if (! $this->hasTable()) {
return [];
}
$today = ($today ?? now())->copy()->startOfDay();
return $this->connection->table(self::TABLE)
->where('balance_date', '<=', $today->toDateString())
->distinct()
->orderByDesc('balance_date')
->pluck('balance_date')
->map(static fn (mixed $date): string => substr((string) $date, 0, 10))
->values()
->all();
}
public static function normalizeStoreName(string $value): string
{
$value = trim(str_replace(['-', '—', '–'], '-', $value));
$value = preg_replace(
'/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|京喜小店|京喜|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-_::]+/u',
'',
$value,
) ?? $value;
$value = preg_replace('/\s+/u', '', $value) ?? $value;
return mb_substr(mb_strtolower($value), 0, 255);
}
public static function sourcePlatform(string $storeName): ?string
{
if (preg_match(
'/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|京喜小店|京喜|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-—–_::]+/u',
trim($storeName),
$matches,
) === 1) {
return $matches[1];
}
return str_ends_with(mb_strtolower(trim($storeName)), '1688') ? '1688' : null;
}
private static function platformKey(string $platform): ?string
{
$platform = mb_strtolower(trim($platform));
foreach ([
'tmall' => ['天猫'],
'taobao' => ['淘宝'],
'1688' => ['1688', '阿里巴巴'],
'jd' => ['京东'],
'jingxi' => ['京喜'],
'pinduoduo' => ['拼多多'],
'douyin' => ['抖音', '头条放心购'],
'kuaishou' => ['快手'],
'xiaohongshu' => ['小红书'],
'vipshop' => ['唯品会'],
'dewu' => ['得物'],
'taogongchang' => ['淘工厂'],
] as $key => $aliases) {
foreach ($aliases as $alias) {
if (str_contains($platform, mb_strtolower($alias))) {
return $key;
}
}
}
return null;
}
/** @return array{status:string,balance:null,source_store_name:null,source_platform:null} */
private function emptyMatch(string $status): array
{
return [
'status' => $status,
'balance' => null,
'source_store_name' => null,
'source_platform' => null,
];
}
private function hasTable(): bool
{
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
}
}