'进费比', '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 */ 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 */ 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 */ public static function filters(Request $request): array { $hasPaidMonthQuery = $request->query->has('paid_month'); $paidMonth = self::normalizeMonth($request->query('paid_month')); if (! $hasPaidMonthQuery) { $paidMonth = now()->subMonthNoOverflow()->format('Y-m'); } $feeRatio = trim((string) $request->query('fee_ratio', '')); if (! array_key_exists($feeRatio, self::FEE_RATIO_OPTIONS)) { $feeRatio = ''; } return [ 'paid_month' => $paidMonth, '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> */ 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> */ 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>,pagination:array,summary:array} */ 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 */ 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'); }); if (($filters['paid_month'] ?? '') !== '') { $monthStart = \Illuminate\Support\Carbon::createFromFormat( 'Y-m-d', $filters['paid_month'] . '-01', )->startOfDay(); $query->where( 'd.paid_at', '>=', $monthStart->toDateString(), )->where( 'd.paid_at', '<', $monthStart->copy()->addMonthNoOverflow()->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> */ 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 */ 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|array> */ private function emptyOptions(): array { return [ 'categories' => [], 'second_levels' => [], 'third_levels' => [], 'allocations' => [], 'fee_ratios' => self::FEE_RATIO_OPTIONS, ]; } /** @return array */ 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 */ 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; } }