CaiWuXiTong/modules/EcommerceFinance/View/ManagementExpensesPageData.php
yueyang 68765b580b feat: 增加OA流程科目管理费用页 (#800)
新增OA已付款流程的二级/三级汇总和Sheet2明细模板页面,接入191条科目映射。
2026-10-30 20:46:17 +08:00

518 lines
19 KiB
PHP

<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Throwable;
class ManagementExpensesPageData
{
public const PER_PAGE_OPTIONS = [50, 100, 200];
public const DEFAULT_PER_PAGE = 50;
public const FEE_RATIO_OPTIONS = [
'in' => '进费比',
'out' => '不进费比',
'unmapped' => '未映射科目',
];
public const DETAIL_COLUMNS = [
'business_id' => '审批编号',
'originator_department' => '部门',
'purpose' => '用途',
'expense_category' => '分类',
'second_level' => '二级',
'third_level' => '三级',
'amount' => '金额',
'fee_ratio' => '是否进费比',
'allocation' => '是否分摊',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/** @return array<string,mixed> */
public function page(Request $request): array
{
$filters = $this->filters($request);
$page = max(1, (int) $request->query('page', 1));
$perPage = self::perPage($request->query('per_page'));
try {
$secondLevel = $this->secondLevelSummary($filters);
$thirdLevel = $this->thirdLevelSummary($filters);
$details = $this->detailResult($filters, $page, $perPage);
$options = $this->options();
return [
'view' => 'business.pages.financial-operations.management-expenses',
'pageTitle' => '管理费用',
'rows' => new LengthAwarePaginator(
$details['rows'],
$details['pagination']['total'],
$perPage,
$page,
[
'path' => $request->url(),
'query' => array_filter(
[...$filters, 'per_page' => $perPage],
static fn (mixed $value): bool => $value !== '' && $value !== null,
),
],
),
'filters' => $filters,
'summary' => $details['summary'],
'secondLevelSummary' => $secondLevel,
'thirdLevelSummary' => $thirdLevel,
'options' => $options,
'perPageOptions' => self::PER_PAGE_OPTIONS,
'detailColumns' => self::DETAIL_COLUMNS,
'detailsUrl' => route(
'business.financial-operations.management-expenses.details',
['company_id' => company_id() ?: 1],
),
'error' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.financial-operations.management-expenses',
'pageTitle' => '管理费用',
'rows' => new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => $request->url(),
'query' => $request->query(),
]),
'filters' => $filters,
'summary' => $this->emptySummary(),
'secondLevelSummary' => [],
'thirdLevelSummary' => [],
'options' => $this->emptyOptions(),
'perPageOptions' => self::PER_PAGE_OPTIONS,
'detailColumns' => self::DETAIL_COLUMNS,
'detailsUrl' => route(
'business.financial-operations.management-expenses.details',
['company_id' => company_id() ?: 1],
),
'error' => '管理费用数据暂时无法读取,请稍后重试。',
];
}
}
/** @return array<string,mixed> */
public function details(Request $request): array
{
$filters = $this->filters($request);
$page = max(1, (int) $request->query('page', 1));
$perPage = self::perPage($request->query('per_page'));
return $this->detailResult($filters, $page, $perPage);
}
/** @return array<string,string> */
public static function filters(Request $request): array
{
[$paidStart, $paidEnd] = self::dateRange(
$request->query('paid_start'),
$request->query('paid_end'),
);
$feeRatio = trim((string) $request->query('fee_ratio', ''));
if (! array_key_exists($feeRatio, self::FEE_RATIO_OPTIONS)) {
$feeRatio = '';
}
return [
'paid_start' => $paidStart,
'paid_end' => $paidEnd,
'expense_category' => mb_substr(trim((string) $request->query('expense_category', '')), 0, 100),
'second_level' => mb_substr(trim((string) $request->query('second_level', '')), 0, 100),
'third_level' => mb_substr(trim((string) $request->query('third_level', '')), 0, 100),
'fee_ratio' => $feeRatio,
'allocation' => mb_substr(trim((string) $request->query('allocation', '')), 0, 100),
'business_id' => mb_substr(trim((string) $request->query('business_id', '')), 0, 32),
'originator_department' => mb_substr(trim((string) $request->query('originator_department', '')), 0, 255),
'originator_name' => mb_substr(trim((string) $request->query('originator_name', '')), 0, 100),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
];
}
/** @return list<array<string,mixed>> */
private function secondLevelSummary(array $filters): array
{
$category = $this->categoryExpression();
$second = $this->secondLevelExpression();
$rows = $this->baseQuery($filters)
->selectRaw("{$category} AS expense_category, {$second} AS second_level")
->selectRaw($this->amountSplitExpression())
->groupByRaw("{$category}, {$second}")
->orderByRaw('SUM(COALESCE(d.amount, 0)) DESC')
->orderByRaw("{$category}, {$second}")
->get();
return $rows->map(function (object $row): array {
$values = (array) $row;
$values['group_key'] = $this->groupKey(
(string) $values['expense_category'],
(string) $values['second_level'],
);
$values['amount_total'] = round((float) $values['amount_total'], 2);
$values['in_fee_ratio_amount'] = round((float) $values['in_fee_ratio_amount'], 2);
$values['non_fee_ratio_amount'] = round((float) $values['non_fee_ratio_amount'], 2);
$values['unmapped_amount'] = round((float) $values['unmapped_amount'], 2);
$values['row_count'] = (int) $values['row_count'];
$values['approval_count'] = (int) $values['approval_count'];
return $values;
})->all();
}
/** @return list<array<string,mixed>> */
private function thirdLevelSummary(array $filters): array
{
$category = $this->categoryExpression();
$second = $this->secondLevelExpression();
$third = $this->thirdLevelExpression();
$rows = $this->baseQuery($filters)
->selectRaw("{$category} AS expense_category, {$second} AS second_level, {$third} AS third_level")
->selectRaw($this->amountSplitExpression())
->groupByRaw("{$category}, {$second}, {$third}")
->orderByRaw('SUM(COALESCE(d.amount, 0)) DESC')
->orderByRaw("{$third}")
->get();
return $rows->map(function (object $row): array {
$values = (array) $row;
$values['group_key'] = $this->groupKey(
(string) $values['expense_category'],
(string) $values['second_level'],
);
$values['amount_total'] = round((float) $values['amount_total'], 2);
$values['in_fee_ratio_amount'] = round((float) $values['in_fee_ratio_amount'], 2);
$values['non_fee_ratio_amount'] = round((float) $values['non_fee_ratio_amount'], 2);
$values['unmapped_amount'] = round((float) $values['unmapped_amount'], 2);
$values['row_count'] = (int) $values['row_count'];
$values['approval_count'] = (int) $values['approval_count'];
return $values;
})->all();
}
/** @return array{rows:list<array<string,mixed>>,pagination:array<string,int>,summary:array<string,int|float>} */
private function detailResult(array $filters, int $page, int $perPage): array
{
$query = $this->baseQuery($filters);
$total = (clone $query)->count();
$rows = $query
->select([
'd.business_id',
'd.originator_department',
'd.purpose',
'd.amount',
'd.line_no',
'd.process_instance_id',
])
->selectRaw($this->categoryExpression() . ' AS expense_category')
->selectRaw($this->secondLevelExpression() . ' AS second_level')
->selectRaw($this->thirdLevelExpression() . ' AS third_level')
->selectRaw($this->feeRatioExpression() . ' AS fee_ratio')
->selectRaw($this->allocationExpression() . ' AS allocation')
->orderByDesc('d.paid_at')
->orderByDesc('d.business_id')
->orderBy('d.line_no')
->forPage($page, $perPage)
->get()
->map(static fn (object $row): array => [
'business_id' => (string) $row->business_id,
'originator_department' => (string) ($row->originator_department ?? ''),
'purpose' => (string) ($row->purpose ?? ''),
'expense_category' => (string) $row->expense_category,
'second_level' => (string) $row->second_level,
'third_level' => (string) $row->third_level,
'amount' => round((float) ($row->amount ?? 0), 2),
'fee_ratio' => (string) $row->fee_ratio,
'allocation' => (string) $row->allocation,
])
->all();
return [
'rows' => $rows,
'pagination' => [
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
'per_page' => $perPage,
'total' => $total,
],
'summary' => $this->summary($filters),
];
}
/** @return array<string,int|float> */
private function summary(array $filters): array
{
$row = $this->baseQuery($filters)
->selectRaw($this->amountSplitExpression())
->first();
$values = $row === null ? [] : (array) $row;
return [
'amount_total' => round((float) ($values['amount_total'] ?? 0), 2),
'in_fee_ratio_amount' => round((float) ($values['in_fee_ratio_amount'] ?? 0), 2),
'non_fee_ratio_amount' => round((float) ($values['non_fee_ratio_amount'] ?? 0), 2),
'unmapped_amount' => round((float) ($values['unmapped_amount'] ?? 0), 2),
'row_count' => (int) ($values['row_count'] ?? 0),
'approval_count' => (int) ($values['approval_count'] ?? 0),
];
}
private function baseQuery(array $filters): Builder
{
$query = $this->connection->table('ding_process_detail_mirror as d')
->leftJoin(
'oa_process_account_mapping as m',
'm.account_code',
'=',
'd.account_code',
)
->where(function (Builder $paid): void {
$paid->where('d.cashier_status', 'completed')
->orWhere(function (Builder $completed): void {
$completed->where('d.status', '已完成')
->whereNotNull('d.payment_source_state')
->where('d.payment_source_state', '<>', 'not_applicable');
});
});
if (($filters['paid_start'] ?? '') !== '') {
$query->where('d.paid_at', '>=', $filters['paid_start']);
}
if (($filters['paid_end'] ?? '') !== '') {
$query->where(
'd.paid_at',
'<',
\Illuminate\Support\Carbon::createFromFormat('Y-m-d', $filters['paid_end'])
->addDay()
->toDateString(),
);
}
if (($filters['expense_category'] ?? '') !== '') {
$query->whereRaw(
$this->categoryExpression() . ' = ?',
[$filters['expense_category']],
);
}
if (($filters['second_level'] ?? '') !== '') {
$query->whereRaw(
$this->secondLevelExpression() . ' = ?',
[$filters['second_level']],
);
}
if (($filters['third_level'] ?? '') !== '') {
$query->whereRaw(
$this->thirdLevelExpression() . ' = ?',
[$filters['third_level']],
);
}
if (($filters['allocation'] ?? '') !== '') {
$query->whereRaw(
$this->allocationExpression() . ' = ?',
[$filters['allocation']],
);
}
if (($filters['fee_ratio'] ?? '') === 'in') {
$query->where('m.in_fee_ratio', 1);
} elseif (($filters['fee_ratio'] ?? '') === 'out') {
$query->where('m.in_fee_ratio', 0);
} elseif (($filters['fee_ratio'] ?? '') === 'unmapped') {
$query->whereNull('m.account_code');
}
foreach ([
'business_id' => 'd.business_id',
'originator_department' => 'd.originator_department',
'originator_name' => 'd.originator_name',
'keyword' => 'd.purpose',
] as $filter => $column) {
$value = trim((string) ($filters[$filter] ?? ''));
if ($value !== '') {
$query->where($column, 'like', '%' . $value . '%');
}
}
return $query;
}
private function amountSplitExpression(): string
{
return 'COUNT(*) AS row_count, '
. 'COUNT(DISTINCT d.business_id) AS approval_count, '
. 'SUM(COALESCE(d.amount, 0)) AS amount_total, '
. 'SUM(CASE WHEN m.in_fee_ratio = 1 THEN COALESCE(d.amount, 0) ELSE 0 END) AS in_fee_ratio_amount, '
. 'SUM(CASE WHEN m.account_code IS NOT NULL AND m.in_fee_ratio = 0 '
. 'THEN COALESCE(d.amount, 0) ELSE 0 END) AS non_fee_ratio_amount, '
. 'SUM(CASE WHEN m.account_code IS NULL THEN COALESCE(d.amount, 0) ELSE 0 END) AS unmapped_amount';
}
private function categoryExpression(): string
{
return "COALESCE(NULLIF(m.expense_category, ''), '未映射科目')";
}
private function secondLevelExpression(): string
{
return "COALESCE(NULLIF(m.second_level, ''), '未映射科目')";
}
private function thirdLevelExpression(): string
{
return "COALESCE(NULLIF(m.third_level, ''), '未细分')";
}
private function feeRatioExpression(): string
{
return "CASE WHEN m.account_code IS NULL THEN '未映射科目' "
. "WHEN m.in_fee_ratio = 1 THEN '进费比' ELSE '不进费比' END";
}
private function allocationExpression(): string
{
return "COALESCE(NULLIF(m.allocation, ''), '未标注')";
}
private function groupKey(string $category, string $secondLevel): string
{
return sha1($category . "\0" . $secondLevel);
}
/** @return array<string,list<string>> */
private function options(): array
{
$table = $this->connection->table('oa_process_account_mapping');
$categories = (clone $table)
->distinct()
->orderBy('expense_category')
->pluck('expense_category')
->map(static fn (mixed $value): string => (string) $value)
->values()
->all();
$secondLevels = (clone $table)
->distinct()
->orderBy('second_level')
->pluck('second_level')
->map(static fn (mixed $value): string => (string) $value)
->values()
->all();
$thirdLevels = (clone $table)
->distinct()
->orderBy('third_level')
->pluck('third_level')
->map(static fn (mixed $value): string => (string) $value)
->values()
->all();
$allocations = (clone $table)
->whereNotNull('allocation')
->where('allocation', '<>', '')
->distinct()
->orderBy('allocation')
->pluck('allocation')
->map(static fn (mixed $value): string => (string) $value)
->values()
->all();
$categories[] = '未映射科目';
$secondLevels[] = '未映射科目';
$thirdLevels[] = '未映射科目';
$allocations[] = '未标注';
$categories = $this->sortOptions($categories);
$secondLevels = $this->sortOptions($secondLevels);
$thirdLevels = $this->sortOptions($thirdLevels);
$allocations = $this->sortOptions($allocations);
return [
'categories' => $categories,
'second_levels' => $secondLevels,
'third_levels' => $thirdLevels,
'allocations' => $allocations,
'fee_ratios' => self::FEE_RATIO_OPTIONS,
];
}
/** @return list<string> */
private function sortOptions(array $values): array
{
$values = array_values(array_unique(array_filter(
$values,
static fn (string $value): bool => $value !== '',
)));
sort($values, SORT_NATURAL | SORT_FLAG_CASE);
return $values;
}
/** @return array<string,list<string>|array<string,string>> */
private function emptyOptions(): array
{
return [
'categories' => [],
'second_levels' => [],
'third_levels' => [],
'allocations' => [],
'fee_ratios' => self::FEE_RATIO_OPTIONS,
];
}
/** @return array<string,int|float> */
private function emptySummary(): array
{
return [
'amount_total' => 0.0,
'in_fee_ratio_amount' => 0.0,
'non_fee_ratio_amount' => 0.0,
'unmapped_amount' => 0.0,
'row_count' => 0,
'approval_count' => 0,
];
}
/** @return array{0:string,1:string} */
private static function dateRange(mixed $start, mixed $end): array
{
$normalize = static function (mixed $value): string {
$date = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $parts) !== 1
|| ! checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) {
return '';
}
return $date;
};
$start = $normalize($start);
$end = $normalize($end);
if ($start !== '' && $end !== '' && $start > $end) {
[$start, $end] = [$end, $start];
}
return [$start, $end];
}
private static function perPage(mixed $value): int
{
$perPage = (int) $value;
return in_array($perPage, self::PER_PAGE_OPTIONS, true)
? $perPage
: self::DEFAULT_PER_PAGE;
}
}