> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。 ## 任务占用与边界 - 任务名称: - 实际开发者: - 任务来源/团队分工: - 目标结果: - 预计修改路径: - 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件): - 仅复用、明确不修改的公共契约: - 数据源、表/视图和字段: - 上游 `source_key` / `scope`: - 权限名、菜单键或共享组件: - 明确不修改的范围: ## 开工碰撞检查 - [ ] Codex 已执行 `scripts/codex-task-preflight.ps1` - [ ] 已检查最新 `main` 和所有远程任务分支 - [ ] 已搜索主线既有页面、接口、Repository 和数据查询 - [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件 - [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论 相关分支、重叠项与协调结论: ## 实际改动 - 完成内容: - Codex 实际修改的文件: - 新增或变更的公共契约: - 与原计划的差异: ## 验证证据 - 正常流程: - 空数据/错误输入/无权限场景: - 受影响旧功能: - 自动测试及结果: - 实际页面、接口或只读业务查询结果: ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用” 高风险影响、回滚和授权状态: ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 `WIP:`,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/258 Co-authored-by: 92451 <924518135@qq.com> Co-committed-by: 92451 <924518135@qq.com>
233 lines
9.4 KiB
PHP
233 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Business;
|
|
|
|
use App\Models\Business\StoreReceivingAccount;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Collection;
|
|
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
|
|
|
class WithdrawalManagementReport
|
|
{
|
|
private const SORT_FIELDS = [
|
|
'platform',
|
|
'erp_store_name',
|
|
'store_id',
|
|
'store_entity',
|
|
'withdrawal_path',
|
|
'withdrawal_account',
|
|
'bank_card',
|
|
'balance',
|
|
'reserve_standard',
|
|
'withdrawal_amount',
|
|
'compliance',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly JushuitanShopBalanceRepository $shopBalances,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus: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}>,
|
|
* reserveStandards:array<int,int|null>,summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
|
|
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
|
|
* }
|
|
*/
|
|
public function results(Request $request): array
|
|
{
|
|
$filters = $this->filters($request);
|
|
$sort = $this->sorting($request);
|
|
$platforms = $this->options('store_platform');
|
|
$paths = $this->options('receiving_method');
|
|
$storeEntities = $this->options('store_entity_name');
|
|
$query = StoreReceivingAccount::query();
|
|
|
|
if ($filters['platform'] !== '') {
|
|
$query->where('store_platform', $filters['platform']);
|
|
}
|
|
if ($filters['withdrawalPath'] !== '') {
|
|
$query->where('receiving_method', $filters['withdrawalPath']);
|
|
}
|
|
if ($filters['storeEntity'] !== '') {
|
|
$query->where('store_entity_name', $filters['storeEntity']);
|
|
}
|
|
if ($filters['keyword'] !== '') {
|
|
$query->where('store_name', 'like', '%' . addcslashes($filters['keyword'], '%_\\') . '%');
|
|
}
|
|
|
|
$today = now()->startOfDay();
|
|
$balanceDate = $today->copy()->subDay()->toDateString();
|
|
$rows = $query->orderByDesc('id')->get();
|
|
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
|
|
$balanceCounts = collect($balances)->countBy(
|
|
static fn (array $match): string => $match['status'],
|
|
);
|
|
|
|
if ($filters['balanceStatus'] !== '') {
|
|
$rows = $rows->filter(
|
|
static fn (StoreReceivingAccount $row): bool => ($balances[(int) $row->id]['status'] ?? 'not_ready') === $filters['balanceStatus'],
|
|
);
|
|
}
|
|
|
|
$reserveStandards = $rows->mapWithKeys(
|
|
static fn (StoreReceivingAccount $row): array => [
|
|
(int) $row->id => WithdrawalReserveStandard::amountFor(
|
|
$row->store_platform,
|
|
$row->erp_store_name,
|
|
),
|
|
],
|
|
)->all();
|
|
$rows = $sort['field'] === ''
|
|
? $this->sortByBalanceStatus($rows, $balances)
|
|
: $this->sortRows($rows, $sort, $balances, $reserveStandards);
|
|
|
|
return [
|
|
'filters' => $filters,
|
|
'platforms' => $platforms,
|
|
'paths' => $paths,
|
|
'storeEntities' => $storeEntities,
|
|
'rows' => $rows,
|
|
'balances' => $balances,
|
|
'reserveStandards' => $reserveStandards,
|
|
'summary' => [
|
|
'source' => $this->shopBalances->countForDate($balanceDate),
|
|
'matched' => (int) $balanceCounts->get('matched', 0),
|
|
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
|
|
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
|
|
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
|
|
],
|
|
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
|
|
'balanceDate' => $balanceDate,
|
|
'sort' => $sort,
|
|
];
|
|
}
|
|
|
|
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
|
|
public function filters(Request $request): array
|
|
{
|
|
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
|
|
$balanceStatus = $clean($request->query('balance_status'));
|
|
|
|
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
|
|
$balanceStatus = '';
|
|
}
|
|
|
|
return [
|
|
'platform' => $clean($request->query('platform')),
|
|
'withdrawalPath' => $clean($request->query('withdrawal_path')),
|
|
'storeEntity' => $clean($request->query('store_entity'), 191),
|
|
'keyword' => $clean($request->query('keyword'), 191),
|
|
'balanceStatus' => $balanceStatus,
|
|
];
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function options(string $column): array
|
|
{
|
|
return StoreReceivingAccount::query()
|
|
->whereNotNull($column)
|
|
->where($column, '<>', '')
|
|
->distinct()
|
|
->orderBy($column)
|
|
->pluck($column)
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/** @return array{field:string,direction:string} */
|
|
private function sorting(Request $request): array
|
|
{
|
|
$field = trim((string) $request->query('sort'));
|
|
$direction = strtolower(trim((string) $request->query('direction')));
|
|
|
|
return [
|
|
'field' => in_array($field, self::SORT_FIELDS, true) ? $field : '',
|
|
'direction' => in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int,StoreReceivingAccount> $rows
|
|
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
|
* @return Collection<int,StoreReceivingAccount>
|
|
*/
|
|
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
|
|
{
|
|
$statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
|
|
|
|
return $rows->sort(
|
|
static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
|
|
$leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
|
|
$rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
|
|
$statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
|
|
|
|
return $statusComparison !== 0
|
|
? $statusComparison
|
|
: ((int) $right->id <=> (int) $left->id);
|
|
},
|
|
)->values();
|
|
}
|
|
|
|
/**
|
|
* @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,int|null> $reserveStandards
|
|
* @return Collection<int,StoreReceivingAccount>
|
|
*/
|
|
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards): Collection
|
|
{
|
|
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards): int {
|
|
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards);
|
|
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards);
|
|
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
|
|
|
|
return $comparison !== 0
|
|
? $comparison
|
|
: ((int) $right->id <=> (int) $left->id);
|
|
})->values();
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
|
* @param array<int,int|null> $reserveStandards
|
|
*/
|
|
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards): mixed
|
|
{
|
|
return match ($field) {
|
|
'platform' => $row->store_platform,
|
|
'erp_store_name' => $row->erp_store_name,
|
|
'store_id' => $row->store_id,
|
|
'store_entity' => $row->store_entity_name,
|
|
'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,
|
|
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function compareSortValues(mixed $left, mixed $right, string $direction): int
|
|
{
|
|
$leftEmpty = $left === null || $left === '';
|
|
$rightEmpty = $right === null || $right === '';
|
|
if ($leftEmpty || $rightEmpty) {
|
|
return $leftEmpty === $rightEmpty ? 0 : ($leftEmpty ? 1 : -1);
|
|
}
|
|
|
|
$comparison = is_numeric($left) && is_numeric($right)
|
|
? ((float) $left <=> (float) $right)
|
|
: strnatcasecmp((string) $left, (string) $right);
|
|
|
|
return $direction === 'desc' ? -$comparison : $comparison;
|
|
}
|
|
}
|