804 lines
35 KiB
PHP
804 lines
35 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\View;
|
||
|
||
use App\Utilities\FinanceLedgerAccountStatus;
|
||
use App\Utilities\FinanceLedgerAccountShouldKeep;
|
||
use App\Utilities\FinanceLedgerAccountType;
|
||
use App\Utilities\FinanceLedgerAccess;
|
||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Database\Query\Builder;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||
|
||
/**
|
||
* 财务台账页面数据。
|
||
*
|
||
* 所有页签一次性读取,页签切换只做前端显示/隐藏;各页签使用独立的分页
|
||
* 参数,避免在同一 URL 上互相覆盖。
|
||
*/
|
||
class FinanceLedgerPageData
|
||
{
|
||
public const TABS = [
|
||
'accounts' => ['title' => '账户总表', 'icon' => 'account_balance'],
|
||
'shops' => ['title' => '店铺台账', 'icon' => 'storefront'],
|
||
'flows' => ['title' => '每日资金流水', 'icon' => 'waterfall_chart'],
|
||
'transactions' => ['title' => '动账明细', 'icon' => 'receipt_long'],
|
||
'mapping' => ['title' => '账户映射', 'icon' => 'alt_route'],
|
||
'sources' => ['title' => '数据来源', 'icon' => 'database'],
|
||
];
|
||
|
||
private const ACCOUNT_FILTER_EMPTY = '__empty__';
|
||
|
||
public function page(?string $tab = null): array
|
||
{
|
||
$tab = (string) (request('tab') ?: $tab);
|
||
$tab = array_key_exists($tab, self::TABS) ? $tab : 'accounts';
|
||
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
|
||
|
||
$accounts = $this->accounts();
|
||
$shops = $this->shops();
|
||
$flows = $this->flows();
|
||
$transactions = $this->transactions();
|
||
|
||
return [
|
||
'view' => 'business.pages.financial-ledger.ledger',
|
||
'ledgerTab' => $tab,
|
||
'ledgerTabs' => self::TABS,
|
||
'ledgerOpeningDate' => $openingDate,
|
||
'ledgerAccounts' => $accounts['rows'],
|
||
'ledgerAccountSummary' => $accounts['summary'],
|
||
'ledgerShops' => $shops['rows'],
|
||
'ledgerShopSummary' => $shops['summary'],
|
||
'ledgerShopBalanceDate' => $this->latestShopBalanceDate(),
|
||
'ledgerFlows' => $flows['rows'],
|
||
'ledgerFlowSummary' => $flows['summary'],
|
||
'ledgerTransactions' => $transactions['rows'],
|
||
'ledgerTransactionSummary' => $transactions['summary'],
|
||
'ledgerMapping' => $this->mapping(),
|
||
'ledgerSourceSummary' => $this->sourceSummary(),
|
||
'ledgerMeta' => $this->meta(),
|
||
'ledgerAccountOptions' => $this->accountOptions(),
|
||
'ledgerManualTransactionAccountOptions' => $this->manualTransactionAccountOptions(),
|
||
'ledgerTransactionAccountHeaderOptions' => $this->transactionAccountHeaderOptions(),
|
||
'ledgerTransactionDateHeaderOptions' => $this->transactionDateHeaderOptions(),
|
||
'ledgerAccountFilterOptions' => $this->accountFilterOptions(),
|
||
'ledgerCompanyOptions' => $this->companyOptions(),
|
||
'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS,
|
||
'ledgerAccountPlanOptions' => $this->accountPlanOptions(),
|
||
'ledgerAccountKeepOptions' => FinanceLedgerAccountShouldKeep::OPTIONS,
|
||
'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()),
|
||
'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()),
|
||
'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()),
|
||
];
|
||
}
|
||
|
||
private function accounts(): array
|
||
{
|
||
$query = $this->db()->table('fin_accounts as a');
|
||
$keyword = trim((string) request('accounts_q', ''));
|
||
if ($keyword !== '') {
|
||
$query->where(function (Builder $builder) use ($keyword): void {
|
||
foreach (['a.company_name', 'a.account_short', 'a.account_no', 'a.owner'] as $column) {
|
||
$builder->orWhere($column, 'like', '%' . $keyword . '%');
|
||
}
|
||
});
|
||
}
|
||
|
||
$accountShort = trim((string) request('accounts_short', ''));
|
||
if ($accountShort !== '') {
|
||
$query->where('a.account_short', 'like', '%' . $accountShort . '%');
|
||
}
|
||
|
||
$status = trim((string) request('accounts_status', ''));
|
||
if ($status !== '' && array_key_exists($status, FinanceLedgerAccountStatus::OPTIONS)) {
|
||
if ($status === FinanceLedgerAccountStatus::NORMAL) {
|
||
$nonNormalStatuses = array_values(array_diff(
|
||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||
[FinanceLedgerAccountStatus::NORMAL],
|
||
));
|
||
$query->whereNotIn(
|
||
'a.account_short',
|
||
$this->db()->table('fin_account_statuses')
|
||
->select('account_short')
|
||
->whereIn('status', $nonNormalStatuses),
|
||
);
|
||
} else {
|
||
$query->whereIn(
|
||
'a.account_short',
|
||
$this->db()->table('fin_account_statuses')
|
||
->select('account_short')
|
||
->where('status', $status),
|
||
);
|
||
}
|
||
}
|
||
|
||
$region = trim((string) request('accounts_region', ''));
|
||
if ($region !== '') {
|
||
if ($region === self::ACCOUNT_FILTER_EMPTY) {
|
||
$query->where(function (Builder $builder): void {
|
||
$builder->whereNull('a.region')->orWhere('a.region', '');
|
||
});
|
||
} else {
|
||
$query->where('a.region', $region);
|
||
}
|
||
}
|
||
|
||
$accountType = trim((string) request('accounts_type', ''));
|
||
if ($accountType !== '') {
|
||
$query->where(function (Builder $effectiveType) use ($accountType): void {
|
||
$effectiveType->where(function (Builder $baseType) use ($accountType): void {
|
||
$baseType->whereNotIn(
|
||
'a.account_short',
|
||
$this->db()->table('fin_account_type_overrides')->select('account_short'),
|
||
);
|
||
if ($accountType === self::ACCOUNT_FILTER_EMPTY) {
|
||
$baseType->where(function (Builder $blank): void {
|
||
$blank->whereNull('a.account_type')->orWhere('a.account_type', '');
|
||
});
|
||
} else {
|
||
$baseType->where('a.account_type', $accountType);
|
||
}
|
||
});
|
||
|
||
$overrideType = $this->db()->table('fin_account_type_overrides')->select('account_short');
|
||
if ($accountType === self::ACCOUNT_FILTER_EMPTY) {
|
||
$overrideType->where(function (Builder $blank): void {
|
||
$blank->whereNull('account_type')->orWhere('account_type', '');
|
||
});
|
||
} else {
|
||
$overrideType->where('account_type', $accountType);
|
||
}
|
||
$effectiveType->orWhereIn('a.account_short', $overrideType);
|
||
});
|
||
}
|
||
|
||
$balanceRange = trim((string) request('accounts_balance', ''));
|
||
if ($balanceRange === '0-5') {
|
||
$query->where('a.current_balance', '>=', 0)->where('a.current_balance', '<', 50000);
|
||
} elseif ($balanceRange === '5-20') {
|
||
$query->where('a.current_balance', '>=', 50000)->where('a.current_balance', '<', 200000);
|
||
} elseif ($balanceRange === '20-plus') {
|
||
$query->where('a.current_balance', '>=', 200000);
|
||
}
|
||
|
||
$headerColumns = $this->accountHeaderColumns();
|
||
$this->applyLedgerHeaderFilter($query, 'accounts', $headerColumns);
|
||
$summaryQuery = clone $query;
|
||
$summary = [
|
||
'count' => (clone $query)->count(),
|
||
'unretained' => FinanceLedgerAccountShouldKeep::applyUnretainedCondition(
|
||
clone $query,
|
||
'a.account_should_keep',
|
||
'a.company_plan',
|
||
)->count(),
|
||
'current_balance' => round((float) (clone $summaryQuery)->sum('a.current_balance'), 2),
|
||
'balance' => round((float) (clone $summaryQuery)->sum('a.daily_available'), 2),
|
||
'blocked' => round((float) (clone $summaryQuery)->sum('a.blocked_amount'), 2),
|
||
];
|
||
|
||
$sorted = $this->applyLedgerHeaderSort($query, 'accounts', $headerColumns);
|
||
if (! $sorted) {
|
||
$query->orderBy('a.company_name')->orderBy('a.account_short');
|
||
}
|
||
$rows = $query
|
||
->paginate(100, ['*'], 'accounts_page')
|
||
->withQueryString();
|
||
|
||
$accountShorts = $rows->pluck('account_short')->filter()->unique()->values()->all();
|
||
$statuses = $accountShorts === []
|
||
? collect()
|
||
: $this->db()->table('fin_account_statuses')
|
||
->whereIn('account_short', $accountShorts)
|
||
->pluck('status', 'account_short');
|
||
$typeOverrides = $accountShorts === []
|
||
? collect()
|
||
: $this->db()->table('fin_account_type_overrides')
|
||
->whereIn('account_short', $accountShorts)
|
||
->pluck('account_type', 'account_short');
|
||
$rows->getCollection()->transform(static function ($row) use ($statuses, $typeOverrides) {
|
||
$row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null);
|
||
if ($typeOverrides->has($row->account_short)) {
|
||
$row->account_type = (string) ($typeOverrides->get($row->account_short) ?? '');
|
||
}
|
||
$row->account_should_keep = FinanceLedgerAccountShouldKeep::display(
|
||
$row->account_should_keep ?? null,
|
||
$row->company_plan ?? null,
|
||
);
|
||
|
||
return $row;
|
||
});
|
||
|
||
return ['rows' => $rows, 'summary' => $summary];
|
||
}
|
||
|
||
private function shops(): array
|
||
{
|
||
$query = $this->db()->table('fin_shops');
|
||
foreach (['shops_q' => ['shop_name', 'platform', 'company_name'], 'shops_platform' => ['platform'], 'shops_company' => ['company_name']] as $param => $columns) {
|
||
$value = trim((string) request($param, ''));
|
||
if ($value === '') {
|
||
continue;
|
||
}
|
||
$query->where(function (Builder $builder) use ($columns, $value): void {
|
||
foreach ($columns as $column) {
|
||
$builder->orWhere($column, 'like', '%' . $value . '%');
|
||
}
|
||
});
|
||
}
|
||
|
||
$headerColumns = $this->shopHeaderColumns();
|
||
$this->applyLedgerHeaderFilter($query, 'shops', $headerColumns);
|
||
$summary = [
|
||
'count' => (clone $query)->count(),
|
||
'balance' => round((float) (clone $query)->sum('shop_balance'), 2),
|
||
];
|
||
|
||
$sorted = $this->applyLedgerHeaderSort($query, 'shops', $headerColumns);
|
||
if (! $sorted) {
|
||
$query->orderBy('company_name')->orderBy('shop_name');
|
||
}
|
||
|
||
return [
|
||
'rows' => $query
|
||
->paginate(100, ['*'], 'shops_page')->withQueryString(),
|
||
'summary' => $summary,
|
||
];
|
||
}
|
||
|
||
private function flows(): array
|
||
{
|
||
$query = $this->db()->table('fin_daily_flows');
|
||
foreach (['flows_date' => 'flow_date', 'flows_company' => 'company_name', 'flows_account' => 'account_short'] as $param => $column) {
|
||
$value = trim((string) request($param, ''));
|
||
if ($value !== '') {
|
||
$query->where($column, $param === 'flows_company' || $param === 'flows_account' ? 'like' : '=', $param === 'flows_company' || $param === 'flows_account' ? '%' . $value . '%' : $value);
|
||
}
|
||
}
|
||
|
||
$headerColumns = $this->flowHeaderColumns();
|
||
$this->applyLedgerHeaderFilter($query, 'flows', $headerColumns);
|
||
$summary = [
|
||
'count' => (clone $query)->count(),
|
||
'closing' => round((float) (clone $query)->sum('closing_balance'), 2),
|
||
'inflow' => round((float) (clone $query)->sum('inflow'), 2),
|
||
'outflow' => round((float) (clone $query)->sum('outflow'), 2),
|
||
];
|
||
|
||
$sorted = $this->applyLedgerHeaderSort($query, 'flows', $headerColumns);
|
||
if (! $sorted) {
|
||
$query->orderBy('flow_date')->orderBy('account_short');
|
||
}
|
||
|
||
return [
|
||
'rows' => $query
|
||
->paginate(100, ['*'], 'flows_page')->withQueryString(),
|
||
'summary' => $summary,
|
||
];
|
||
}
|
||
|
||
private function transactions(): array
|
||
{
|
||
$query = $this->transactionQuery();
|
||
$this->applyTransactionFilters($query);
|
||
|
||
$headerColumns = $this->transactionHeaderColumns();
|
||
$summary = $this->transactionSummaryMetrics($query);
|
||
$sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns);
|
||
if (! $sorted) {
|
||
$query->orderByDesc('t.txn_date')->orderByDesc('t.id');
|
||
}
|
||
|
||
return [
|
||
'rows' => $query
|
||
->paginate(100, ['*'], 'txns_page')->withQueryString(),
|
||
'summary' => $summary,
|
||
];
|
||
}
|
||
|
||
public function transactionSummary(): array
|
||
{
|
||
$query = $this->transactionQuery();
|
||
$this->applyTransactionFilters($query);
|
||
|
||
return $this->transactionSummaryMetrics($query);
|
||
}
|
||
|
||
private function applyTransactionFilters(Builder $query): void
|
||
{
|
||
$keyword = trim((string) request('txns_q', ''));
|
||
if ($keyword !== '') {
|
||
$query->where(function (Builder $builder) use ($keyword): void {
|
||
$builder->where('t.detail', 'like', '%' . $keyword . '%')
|
||
->orWhere('t.approval_no', 'like', '%' . $keyword . '%')
|
||
->orWhere('t.account_short', 'like', '%' . $keyword . '%')
|
||
->orWhere('m.account_short', 'like', '%' . $keyword . '%');
|
||
});
|
||
}
|
||
$account = trim((string) request('txns_account', ''));
|
||
if ($account !== '') {
|
||
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
|
||
$query->whereRaw($effectiveAccount . ' LIKE ?', ['%' . $account . '%']);
|
||
}
|
||
$from = trim((string) request('txns_from', ''));
|
||
if ($from !== '') {
|
||
$query->where('t.txn_date', '>=', $from);
|
||
}
|
||
$to = trim((string) request('txns_to', ''));
|
||
if ($to !== '') {
|
||
$query->where('t.txn_date', '<=', $to);
|
||
}
|
||
$source = trim((string) request('txns_source', ''));
|
||
if ($source !== '') {
|
||
$query->where('t.source', $source);
|
||
}
|
||
|
||
$this->applyLedgerHeaderFilter($query, 'transactions', $this->transactionHeaderColumns());
|
||
}
|
||
|
||
private function transactionSummaryMetrics(Builder $query): array
|
||
{
|
||
return [
|
||
'count' => (clone $query)->count(),
|
||
'income' => round((float) (clone $query)->sum('t.income'), 2),
|
||
'expense' => round((float) (clone $query)->sum('t.expense'), 2),
|
||
];
|
||
}
|
||
|
||
private function transactionQuery(): Builder
|
||
{
|
||
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
|
||
|
||
return $this->db()->table('fin_transactions as t')
|
||
->leftJoin('fin_account_map as m', function ($join): void {
|
||
$join->on('m.source', '=', 't.source')->on('m.source_value', '=', 't.source_value');
|
||
})
|
||
->select([
|
||
't.id',
|
||
't.txn_date',
|
||
't.account_short',
|
||
't.detail',
|
||
't.income',
|
||
't.expense',
|
||
't.approval_no',
|
||
't.source',
|
||
't.source_value',
|
||
't.process_type',
|
||
't.row_key',
|
||
't.synced_at',
|
||
't.created_at',
|
||
't.updated_at',
|
||
])
|
||
->selectRaw($effectiveAccount . ' AS effective_account_short');
|
||
}
|
||
|
||
private function transactionAccountHeaderOptions(): array
|
||
{
|
||
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
|
||
|
||
return $this->db()->table('fin_transactions as t')
|
||
->leftJoin('fin_account_map as m', function ($join): void {
|
||
$join->on('m.source', '=', 't.source')
|
||
->on('m.source_value', '=', 't.source_value');
|
||
})
|
||
->selectRaw($effectiveAccount . ' AS filter_value')
|
||
->distinct()
|
||
->orderBy('filter_value')
|
||
->pluck('filter_value')
|
||
->map(static fn (mixed $value): string => trim((string) $value))
|
||
->filter()
|
||
->unique()
|
||
->mapWithKeys(static fn (string $value): array => [$value => $value])
|
||
->all();
|
||
}
|
||
|
||
private function transactionDateHeaderOptions(): array
|
||
{
|
||
return $this->db()->table('fin_transactions')
|
||
->select('txn_date')
|
||
->distinct()
|
||
->orderByDesc('txn_date')
|
||
->pluck('txn_date')
|
||
->map(static fn (mixed $value): string => (string) $value)
|
||
->filter()
|
||
->unique()
|
||
->mapWithKeys(static fn (string $value): array => [$value => $value])
|
||
->all();
|
||
}
|
||
|
||
private function mapping(): array
|
||
{
|
||
$query = $this->db()->table('fin_account_map');
|
||
$source = trim((string) request('mapping_source', ''));
|
||
if ($source !== '') {
|
||
$query->where('source', $source);
|
||
}
|
||
$keyword = trim((string) request('mapping_q', ''));
|
||
if ($keyword !== '') {
|
||
$query->where(function (Builder $builder) use ($keyword): void {
|
||
$builder->where('source_value', 'like', '%' . $keyword . '%')
|
||
->orWhere('account_short', 'like', '%' . $keyword . '%');
|
||
});
|
||
}
|
||
if (request()->boolean('mapping_unmapped')) {
|
||
$query->where(function (Builder $builder): void {
|
||
$builder->whereNull('account_short')->orWhere('account_short', '');
|
||
});
|
||
}
|
||
|
||
$headerColumns = $this->mappingHeaderColumns();
|
||
$this->applyLedgerHeaderFilter($query, 'mapping', $headerColumns);
|
||
$total = (clone $query)->count();
|
||
$mapped = (clone $query)->whereNotNull('account_short')->where('account_short', '<>', '')->count();
|
||
$sorted = $this->applyLedgerHeaderSort($query, 'mapping', $headerColumns);
|
||
if (! $sorted) {
|
||
$query->orderByDesc('txn_count')->orderBy('source_value');
|
||
}
|
||
|
||
return [
|
||
'rows' => $query
|
||
->paginate(100, ['*'], 'mapping_page')->withQueryString(),
|
||
'total' => $total,
|
||
'mapped' => $mapped,
|
||
'all_total' => $this->db()->table('fin_account_map')->count(),
|
||
'all_mapped' => $this->db()->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count(),
|
||
'sources' => $this->db()->table('fin_account_map')->select('source')->distinct()->orderBy('source')->pluck('source'),
|
||
];
|
||
}
|
||
|
||
private function sourceSummary(): array
|
||
{
|
||
$rows = $this->db()->table('fin_transactions')
|
||
->selectRaw('source, process_type, COUNT(*) AS rows_count, SUM(income) AS income, SUM(expense) AS expense, MIN(txn_date) AS first_date, MAX(txn_date) AS last_date')
|
||
->groupBy('source', 'process_type')
|
||
->orderBy('source')
|
||
->orderByDesc('rows_count')
|
||
->get()
|
||
->map(static function ($row): array {
|
||
$data = (array) $row;
|
||
$data['date_range'] = trim((string) ($data['first_date'] ?? '') . ' ~ ' . (string) ($data['last_date'] ?? ''));
|
||
|
||
return $data;
|
||
})
|
||
->all();
|
||
|
||
return $this->filterAndSortHeaderRows($rows, 'sources', $this->sourceHeaderColumns());
|
||
}
|
||
|
||
private function meta(): array
|
||
{
|
||
return [
|
||
'accounts' => $this->db()->table('fin_accounts')->count(),
|
||
'shops' => $this->db()->table('fin_shops')->count(),
|
||
'companies' => $this->db()->table('fin_companies')->count(),
|
||
'opening_rows' => $this->db()->table('fin_account_opening')->count(),
|
||
'opening_sum' => round((float) $this->db()->table('fin_account_opening')->sum('opening_balance'), 2),
|
||
'transactions' => $this->db()->table('fin_transactions')->count(),
|
||
'income' => round((float) $this->db()->table('fin_transactions')->sum('income'), 2),
|
||
'expense' => round((float) $this->db()->table('fin_transactions')->sum('expense'), 2),
|
||
'flows' => $this->db()->table('fin_daily_flows')->count(),
|
||
'mapping_total' => $this->db()->table('fin_account_map')->count(),
|
||
'mapping_done' => $this->db()->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count(),
|
||
'last_synced_at' => $this->db()->table('fin_transactions')->max('synced_at'),
|
||
];
|
||
}
|
||
|
||
private function latestShopBalanceDate(): ?string
|
||
{
|
||
if (! Schema::connection($this->db()->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) {
|
||
return null;
|
||
}
|
||
|
||
$latest = $this->db()->table(JushuitanShopBalanceRepository::TABLE)
|
||
->where('balance_date', '<=', now()->startOfDay()->subDay()->toDateString())
|
||
->max('balance_date');
|
||
|
||
return $latest === null ? null : substr((string) $latest, 0, 10);
|
||
}
|
||
|
||
private function accountOptions(): array
|
||
{
|
||
return $this->db()->table('fin_accounts')
|
||
->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all();
|
||
}
|
||
|
||
private function manualTransactionAccountOptions(): array
|
||
{
|
||
return $this->db()->table('fin_accounts')
|
||
->select(['account_short', 'company_name'])
|
||
->whereNotNull('account_short')
|
||
->where('account_short', '<>', '')
|
||
->orderBy('account_short')
|
||
->orderBy('company_name')
|
||
->orderBy('id')
|
||
->get()
|
||
->groupBy('account_short')
|
||
->map(static function ($rows, string $accountShort): array {
|
||
$first = $rows->first();
|
||
$companyName = trim((string) ($first->company_name ?? ''));
|
||
$accountCount = $rows->count();
|
||
|
||
return [
|
||
'value' => $accountShort,
|
||
'label' => $accountShort
|
||
. ($companyName !== '' ? ' · ' . $companyName : '')
|
||
. ($accountCount > 1 ? '(' . $accountCount . ' 个账号)' : ''),
|
||
];
|
||
})
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function accountFilterOptions(): array
|
||
{
|
||
$baseTypes = $this->db()->table('fin_accounts')
|
||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||
->distinct()->orderBy('account_type')->pluck('account_type')->all();
|
||
$overrideTypes = $this->db()->table('fin_account_type_overrides')
|
||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||
->distinct()->orderBy('account_type')->pluck('account_type')->all();
|
||
$types = FinanceLedgerAccountType::options($baseTypes, $overrideTypes);
|
||
|
||
return [
|
||
'regions' => $this->db()->table('fin_accounts')
|
||
->whereNotNull('region')->where('region', '<>', '')
|
||
->distinct()->orderBy('region')->pluck('region')->all(),
|
||
'types' => $types,
|
||
];
|
||
}
|
||
|
||
private function accountPlanOptions(): array
|
||
{
|
||
return $this->db()->table('fin_accounts')
|
||
->whereNotNull('company_plan')
|
||
->where('company_plan', '<>', '')
|
||
->distinct()
|
||
->orderBy('company_plan')
|
||
->pluck('company_plan')
|
||
->map(static fn (mixed $value): string => trim((string) $value))
|
||
->filter()
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function companyOptions(): array
|
||
{
|
||
return $this->db()->table('fin_companies')
|
||
->whereNotNull('name')
|
||
->pluck('name')
|
||
->merge(
|
||
$this->db()->table('fin_accounts')
|
||
->whereNotNull('company_name')
|
||
->distinct()
|
||
->pluck('company_name'),
|
||
)
|
||
->map(static fn (mixed $value): string => trim((string) $value))
|
||
->filter()
|
||
->unique()
|
||
->sortBy(static fn (string $value): string => mb_strtolower($value))
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function applyLedgerHeaderFilter(Builder $query, string $tab, array $columns): void
|
||
{
|
||
$column = trim((string) request($tab . '_filter_column', ''));
|
||
$value = trim((string) request($tab . '_filter_value', ''));
|
||
if ($value === '' || ! isset($columns[$column])) {
|
||
return;
|
||
}
|
||
|
||
$definition = $columns[$column];
|
||
$expression = $definition['filter'] ?? $definition['sort'] ?? null;
|
||
if (! is_string($expression) || $expression === '') {
|
||
return;
|
||
}
|
||
|
||
$type = $definition['type'] ?? 'text';
|
||
if ($type === 'number') {
|
||
if (is_numeric($value)) {
|
||
$query->whereRaw($expression . ' = ?', [$value]);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (in_array($type, ['enum', 'date'], true)) {
|
||
$query->whereRaw($expression . ' = ?', [$value]);
|
||
|
||
return;
|
||
}
|
||
|
||
$query->whereRaw($expression . ' LIKE ?', ['%' . $value . '%']);
|
||
}
|
||
|
||
private function applyLedgerHeaderSort(Builder $query, string $tab, array $columns): bool
|
||
{
|
||
$column = trim((string) request($tab . '_sort', ''));
|
||
if (! isset($columns[$column])) {
|
||
return false;
|
||
}
|
||
|
||
$expression = $columns[$column]['sort'] ?? null;
|
||
if (! is_string($expression) || $expression === '') {
|
||
return false;
|
||
}
|
||
|
||
$direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? 'desc' : 'asc';
|
||
$query->orderByRaw($expression . ' ' . $direction);
|
||
|
||
return true;
|
||
}
|
||
|
||
private function filterAndSortHeaderRows(array $rows, string $tab, array $columns): array
|
||
{
|
||
$filterColumn = trim((string) request($tab . '_filter_column', ''));
|
||
$filterValue = trim((string) request($tab . '_filter_value', ''));
|
||
if ($filterValue !== '' && isset($columns[$filterColumn])) {
|
||
$valueColumn = $columns[$filterColumn]['value'] ?? $filterColumn;
|
||
$type = $columns[$filterColumn]['type'] ?? 'text';
|
||
$rows = array_values(array_filter(
|
||
$rows,
|
||
static function (array $row) use ($valueColumn, $filterValue, $type): bool {
|
||
$value = trim((string) ($row[$valueColumn] ?? ''));
|
||
if ($type === 'number') {
|
||
return is_numeric($filterValue) && is_numeric($value) && (float) $value === (float) $filterValue;
|
||
}
|
||
if (in_array($type, ['enum', 'date'], true)) {
|
||
return $value === $filterValue;
|
||
}
|
||
|
||
return mb_stripos($value, $filterValue) !== false;
|
||
},
|
||
));
|
||
}
|
||
|
||
$sortColumn = trim((string) request($tab . '_sort', ''));
|
||
if (isset($columns[$sortColumn])) {
|
||
$valueColumn = $columns[$sortColumn]['value'] ?? $sortColumn;
|
||
$direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? -1 : 1;
|
||
$type = $columns[$sortColumn]['type'] ?? 'text';
|
||
usort($rows, static function (array $left, array $right) use ($valueColumn, $direction, $type): int {
|
||
$leftValue = $left[$valueColumn] ?? null;
|
||
$rightValue = $right[$valueColumn] ?? null;
|
||
if ($type === 'number') {
|
||
return ((float) $leftValue <=> (float) $rightValue) * $direction;
|
||
}
|
||
|
||
return strnatcasecmp((string) $leftValue, (string) $rightValue) * $direction;
|
||
});
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
private function accountHeaderColumns(): array
|
||
{
|
||
$a = $this->queryAlias('a');
|
||
$statusTable = $this->tableName('fin_account_statuses');
|
||
$typeTable = $this->tableName('fin_account_type_overrides');
|
||
$statusExpression = "COALESCE((SELECT NULLIF(s.status, '') FROM {$statusTable} s WHERE s.account_short = {$a}.account_short LIMIT 1), 'normal')";
|
||
$typeExpression = "COALESCE((SELECT NULLIF(o.account_type, '') FROM {$typeTable} o WHERE o.account_short = {$a}.account_short LIMIT 1), NULLIF({$a}.account_type, ''), '')";
|
||
$keepExpression = "COALESCE(NULLIF({$a}.account_should_keep, ''), CASE WHEN COALESCE({$a}.company_plan, '') = '' THEN '' WHEN {$a}.company_plan LIKE '%保留%' THEN '是' ELSE '否' END)";
|
||
|
||
return [
|
||
'company_name' => ['sort' => $a . '.company_name', 'type' => 'text'],
|
||
'account_short' => ['sort' => $a . '.account_short', 'type' => 'text'],
|
||
'account_type' => ['sort' => $typeExpression, 'type' => 'enum'],
|
||
'account_status' => ['sort' => $statusExpression, 'type' => 'enum'],
|
||
'region' => ['sort' => $a . '.region', 'type' => 'text'],
|
||
'account_no' => ['sort' => $a . '.account_no', 'type' => 'text'],
|
||
'bank_name' => ['sort' => $a . '.bank_name', 'type' => 'text'],
|
||
'bank_city_branch' => ['sort' => $a . '.bank_city_branch', 'type' => 'text'],
|
||
'company_plan' => ['sort' => $a . '.company_plan', 'type' => 'enum'],
|
||
'account_should_keep' => ['sort' => $keepExpression, 'type' => 'enum'],
|
||
'account_last4' => ['sort' => $a . '.account_last4', 'type' => 'text'],
|
||
'single_limit' => ['sort' => "CAST({$a}.single_limit AS DECIMAL(18, 2))", 'filter' => $a . '.single_limit', 'type' => 'number'],
|
||
'daily_limit' => ['sort' => "CAST({$a}.daily_limit AS DECIMAL(18, 2))", 'filter' => $a . '.daily_limit', 'type' => 'number'],
|
||
'monthly_limit' => ['sort' => "CAST({$a}.monthly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.monthly_limit', 'type' => 'number'],
|
||
'yearly_limit' => ['sort' => "CAST({$a}.yearly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.yearly_limit', 'type' => 'number'],
|
||
'current_balance' => ['sort' => $a . '.current_balance', 'type' => 'number'],
|
||
'daily_available' => ['sort' => $a . '.daily_available', 'type' => 'number'],
|
||
'blocked_amount' => ['sort' => $a . '.blocked_amount', 'type' => 'number'],
|
||
'daily_withdraw' => ['sort' => $a . '.daily_available', 'type' => 'number'],
|
||
'clearing_days' => ['sort' => $a . '.clearing_days', 'type' => 'number'],
|
||
'raise_target' => ['sort' => "CAST({$a}.raise_target AS DECIMAL(18, 2))", 'filter' => $a . '.raise_target', 'type' => 'number'],
|
||
'raise_status' => ['sort' => $a . '.raise_status', 'type' => 'text'],
|
||
'raise_actions' => ['sort' => $a . '.raise_actions', 'type' => 'text'],
|
||
'done_actions' => ['sort' => $a . '.done_actions', 'type' => 'text'],
|
||
'next_action' => ['sort' => $a . '.next_action', 'type' => 'text'],
|
||
'remark' => ['sort' => $a . '.remark', 'type' => 'text'],
|
||
'owner' => ['sort' => $a . '.owner', 'type' => 'text'],
|
||
];
|
||
}
|
||
|
||
private function shopHeaderColumns(): array
|
||
{
|
||
return [
|
||
'platform' => ['sort' => 'platform', 'type' => 'text'],
|
||
'shop_name' => ['sort' => 'shop_name', 'type' => 'text'],
|
||
'company_name' => ['sort' => 'company_name', 'type' => 'text'],
|
||
'account_short' => ['sort' => 'account_short', 'type' => 'text'],
|
||
'status' => ['sort' => 'status', 'type' => 'text'],
|
||
'payout_account' => ['sort' => 'payout_account', 'type' => 'text'],
|
||
'shop_balance' => ['sort' => 'shop_balance', 'type' => 'number'],
|
||
'remark' => ['sort' => 'remark', 'type' => 'text'],
|
||
];
|
||
}
|
||
|
||
private function flowHeaderColumns(): array
|
||
{
|
||
return [
|
||
'flow_date' => ['sort' => 'flow_date', 'type' => 'date'],
|
||
'company_name' => ['sort' => 'company_name', 'type' => 'text'],
|
||
'account_short' => ['sort' => 'account_short', 'type' => 'text'],
|
||
'opening_balance' => ['sort' => 'opening_balance', 'type' => 'number'],
|
||
'inflow' => ['sort' => 'inflow', 'type' => 'number'],
|
||
'outflow' => ['sort' => 'outflow', 'type' => 'number'],
|
||
'closing_balance' => ['sort' => 'closing_balance', 'type' => 'number'],
|
||
'remark' => ['sort' => 'remark', 'type' => 'text'],
|
||
];
|
||
}
|
||
|
||
private function transactionHeaderColumns(): array
|
||
{
|
||
$t = $this->queryAlias('t');
|
||
$m = $this->queryAlias('m');
|
||
$effectiveAccount = "COALESCE(NULLIF({$m}.account_short, ''), NULLIF({$t}.account_short, ''), '未匹配')";
|
||
|
||
return [
|
||
'txn_date' => ['sort' => $t . '.txn_date', 'type' => 'date'],
|
||
'effective_account_short' => ['sort' => $effectiveAccount, 'type' => 'text'],
|
||
'detail' => ['sort' => $t . '.detail', 'type' => 'text'],
|
||
'source' => ['sort' => $t . '.source', 'type' => 'enum'],
|
||
'income' => ['sort' => $t . '.income', 'type' => 'number'],
|
||
'expense' => ['sort' => $t . '.expense', 'type' => 'number'],
|
||
'approval_no' => ['sort' => $t . '.approval_no', 'type' => 'text'],
|
||
];
|
||
}
|
||
|
||
private function mappingHeaderColumns(): array
|
||
{
|
||
$mappingStatus = "CASE WHEN COALESCE(account_short, '') = '' THEN 'unmapped' ELSE 'mapped' END";
|
||
|
||
return [
|
||
'source' => ['sort' => 'source', 'type' => 'enum'],
|
||
'source_value' => ['sort' => 'source_value', 'type' => 'text'],
|
||
'txn_count' => ['sort' => 'txn_count', 'type' => 'number'],
|
||
'income_sum' => ['sort' => 'income_sum', 'type' => 'number'],
|
||
'expense_sum' => ['sort' => 'expense_sum', 'type' => 'number'],
|
||
'account_short' => ['sort' => 'account_short', 'type' => 'text'],
|
||
'mapping_status' => ['sort' => $mappingStatus, 'type' => 'enum'],
|
||
];
|
||
}
|
||
|
||
private function sourceHeaderColumns(): array
|
||
{
|
||
return [
|
||
'source' => ['value' => 'source', 'type' => 'enum'],
|
||
'process_type' => ['value' => 'process_type', 'type' => 'text'],
|
||
'rows_count' => ['value' => 'rows_count', 'type' => 'number'],
|
||
'income' => ['value' => 'income', 'type' => 'number'],
|
||
'expense' => ['value' => 'expense', 'type' => 'number'],
|
||
'date_range' => ['value' => 'date_range', 'type' => 'text'],
|
||
];
|
||
}
|
||
|
||
private function db()
|
||
{
|
||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
}
|
||
|
||
private function tableName(string $table): string
|
||
{
|
||
return $this->db()->getTablePrefix() . $table;
|
||
}
|
||
|
||
private function queryAlias(string $alias): string
|
||
{
|
||
return $this->db()->getTablePrefix() . $alias;
|
||
}
|
||
}
|