529 lines
20 KiB
PHP
529 lines
20 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);
|
|
$monthOptions = $this->monthOptions();
|
|
$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,
|
|
'monthOptions' => $monthOptions,
|
|
'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(),
|
|
'monthOptions' => $monthOptions,
|
|
'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
|
|
{
|
|
$hasApprovalMonthQuery = $request->query->has('approved_month');
|
|
$approvedMonth = self::normalizeMonth($request->query('approved_month'));
|
|
if (! $hasApprovalMonthQuery) {
|
|
$approvedMonth = now()->subMonthNoOverflow()->format('Y-m');
|
|
}
|
|
$feeRatio = trim((string) $request->query('fee_ratio', ''));
|
|
if (! array_key_exists($feeRatio, self::FEE_RATIO_OPTIONS)) {
|
|
$feeRatio = '';
|
|
}
|
|
|
|
return [
|
|
'approved_month' => $approvedMonth,
|
|
'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.approval_finished_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('d.status', '已完成')
|
|
->where('d.cashier_status', 'completed')
|
|
->where(function (Builder $paymentSource): void {
|
|
$paymentSource->whereNull('d.payment_source_state')
|
|
->orWhere('d.payment_source_state', '<>', 'not_applicable');
|
|
})
|
|
->whereNotNull('d.approval_finished_at');
|
|
|
|
if (($filters['approved_month'] ?? '') !== '') {
|
|
$monthStart = \Illuminate\Support\Carbon::createFromFormat(
|
|
'Y-m-d',
|
|
$filters['approved_month'] . '-01',
|
|
)->startOfDay();
|
|
$query->where(
|
|
'd.approval_finished_at',
|
|
'>=',
|
|
$monthStart->toDateTimeString(),
|
|
)->where(
|
|
'd.approval_finished_at',
|
|
'<',
|
|
$monthStart->copy()->addMonthNoOverflow()->toDateTimeString(),
|
|
);
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
private static function normalizeMonth(mixed $value): string
|
|
{
|
|
$month = trim((string) $value);
|
|
if (preg_match('/^(\d{4})-(\d{2})$/', $month, $parts) !== 1
|
|
|| ! checkdate((int) $parts[2], 1, (int) $parts[1])) {
|
|
return '';
|
|
}
|
|
|
|
return $month;
|
|
}
|
|
|
|
/** @return array<string,string> */
|
|
private function monthOptions(): array
|
|
{
|
|
$current = now()->startOfMonth();
|
|
$configuredStart = \Illuminate\Support\Carbon::parse(
|
|
(string) config('upstream_mirrors.ding_process_details.start_date', '2026-01-01'),
|
|
)->startOfMonth();
|
|
$start = $configuredStart->lte($current) ? $configuredStart : $current->copy();
|
|
$options = [];
|
|
for ($month = $current->copy(); $month->gte($start); $month->subMonthNoOverflow()) {
|
|
$options[$month->format('Y-m')] = $month->format('Y年n月');
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private static function perPage(mixed $value): int
|
|
{
|
|
$perPage = (int) $value;
|
|
|
|
return in_array($perPage, self::PER_PAGE_OPTIONS, true)
|
|
? $perPage
|
|
: self::DEFAULT_PER_PAGE;
|
|
}
|
|
}
|