CaiWuXiTong/modules/EcommerceFinance/View/FinanceLedgerPageData.php

1257 lines
53 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\View;
use App\Business\WithdrawalBalanceAlertSynchronizer;
use App\Business\WithdrawalReserveStandard;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use App\Utilities\FinanceLedgerAccess;
use Illuminate\Database\Query\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDirectory;
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
use Modules\EcommerceFinance\Services\StoreReceivingBillBalanceSnapshotSynchronizer;
/**
* 财务台账页面数据。
*
* 所有页签一次性读取,页签切换只做前端显示/隐藏;各页签使用独立的分页
* 参数,避免在同一 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'],
];
public const TRANSACTION_VIEWS = [
'all' => ['title' => '全部明细', 'account_short' => null],
'zhu-junxiang-alipay' => ['title' => '朱俊祥支付宝', 'account_short' => '朱俊祥支付宝'],
'zhu-junxiang-wechat' => ['title' => '朱俊祥微信', 'account_short' => '朱俊祥微信'],
];
private const ACCOUNT_FILTER_EMPTY = '__empty__';
public function __construct(
private readonly FinanceLedgerShopDirectory $shopDirectory,
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
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');
$transactionView = $this->transactionView();
$flowPeriod = $this->dateRange('flows');
$transactionPeriod = $this->dateRange('txns');
$mappingPeriod = $this->dateRange('mapping');
$sourcePeriod = $this->dateRange('sources');
$accounts = $this->accounts();
$shops = $this->shops();
$availableBreakdown = $this->availableBreakdown($shops);
$flows = $this->flows($flowPeriod);
$transactions = $this->transactions($transactionView['account_short'], $transactionPeriod);
$transactionViewAccountExists = $transactionView['account_short'] === null
|| $this->db()->table('fin_accounts')
->where('account_short', $transactionView['account_short'])
->exists();
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' => $shops['balance_date'] ?? null,
'ledgerShopBalanceDates' => $shops['balance_dates'] ?? [],
'ledgerAvailableBreakdown' => $availableBreakdown,
'ledgerFlows' => $flows['rows'],
'ledgerFlowSummary' => $flows['summary'],
'ledgerFlowPeriod' => $flowPeriod,
'ledgerTransactions' => $transactions['rows'],
'ledgerTransactionSummary' => $transactions['summary'],
'ledgerTransactionPeriod' => $transactionPeriod,
'ledgerTransactionView' => $transactionView['key'],
'ledgerTransactionViews' => self::TRANSACTION_VIEWS,
'ledgerTransactionViewAccount' => $transactionView['account_short'],
'ledgerTransactionViewAccountExists' => $transactionViewAccountExists,
'ledgerMapping' => $this->mapping($mappingPeriod),
'ledgerMappingPeriod' => $mappingPeriod,
'ledgerSourceSummary' => $this->sourceSummary($sourcePeriod),
'ledgerSourcePeriod' => $sourcePeriod,
'ledgerMeta' => $this->meta($transactionPeriod, $flowPeriod),
'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()),
'ledgerCanEditTransactions' => FinanceLedgerAccess::canEditTransactions(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
{
$directoryRows = $this->shopDirectory->rows();
if ($directoryRows === null) {
return $this->shopsFromLedgerTable();
}
$context = $this->storeLedgerBalanceContext($directoryRows);
return $this->shopsFromStoreReport($context['rows'], $context);
}
/**
* 店铺余额按日期读取专用快照。店铺收款账户账单有真实余额的平台
* 优先使用账单来源,其余平台由同步任务以聚水潭余额补足并明确标注。
*
* @param list<array<string,mixed>> $rows
* @return array{
* rows:list<array<string,mixed>>,balance_date:?string,
* balance_dates:list<string>,alert_count:int
* }
*/
private function storeLedgerBalanceContext(array $rows): array
{
$latestAllowed = now()->startOfDay()->subDay();
$balanceDates = $this->shopBalances->availableDates(
$latestAllowed,
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
);
$requestedDate = trim((string) request('shops_balance_date', ''));
$balanceDate = in_array($requestedDate, $balanceDates, true)
? $requestedDate
: ($balanceDates[0] ?? null);
$reserveStandards = [];
foreach ($rows as $index => $row) {
$reserveStandards[(int) ($row['id'] ?? $index)] = WithdrawalReserveStandard::amountFor(
(string) ($row['platform'] ?? ''),
(string) ($row['shop_name'] ?? ''),
);
$rows[$index]['shop_balance_source'] = ($row['shop_balance_date'] ?? null)
? StoreReceivingBillBalanceSnapshotSynchronizer::sourceLabel(
JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN,
)
: '未同步';
$rows[$index]['shop_balance_status'] = ($row['shop_balance_date'] ?? null) ? 'matched' : 'not_ready';
$rows[$index]['reserve_standard'] = $reserveStandards[(int) ($row['id'] ?? $index)];
$rows[$index]['balance_alert'] = '否';
$rows[$index]['balance_alert_message'] = null;
}
if ($balanceDate === null) {
return [
'rows' => $rows,
'balance_date' => null,
'balance_dates' => [],
'alert_count' => 0,
];
}
$stores = array_map(
static fn (array $row): object => (object) $row,
$rows,
);
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
$matches = $this->shopBalances->matchesForStoresOnDates(
$stores,
$historyDates,
null,
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
);
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
$stores,
$matches,
array_map(
static fn (?int $amount): ?int => $amount === null ? null : (int) $amount,
$reserveStandards,
),
$historyDates,
);
foreach ($rows as $index => $row) {
$id = (int) ($row['id'] ?? $index);
$match = $matches[$id][$balanceDate] ?? [
'status' => 'not_ready',
'balance' => null,
'data_source' => null,
'source_file_name' => null,
];
$matched = ($match['status'] ?? '') === 'matched' && ($match['balance'] ?? null) !== null;
$rows[$index]['shop_balance'] = $matched ? round((float) $match['balance'], 2) : null;
$rows[$index]['shop_balance_date'] = $matched ? $balanceDate : null;
$rows[$index]['shop_balance_status'] = (string) ($match['status'] ?? 'not_ready');
$rows[$index]['shop_balance_source'] = $matched
? StoreReceivingBillBalanceSnapshotSynchronizer::sourceLabel(
$match['data_source'] ?? null,
$match['source_file_name'] ?? null,
)
: '未同步';
$alert = $alerts[$id] ?? null;
$rows[$index]['balance_alert'] = $alert === null ? '否' : '是';
$rows[$index]['balance_alert_message'] = $alert['message'] ?? null;
}
return [
'rows' => $rows,
'balance_date' => $balanceDate,
'balance_dates' => $balanceDates,
'alert_count' => count($alerts),
];
}
/**
* @param list<array<string,mixed>> $rows
* @param array{balance_date:?string,balance_dates:list<string>,alert_count:int}|array{} $context
* @return array{
* rows:LengthAwarePaginator,summary:array{count:int,balance:float,alert_count:int},
* overall_summary:array{count:int,balance:float},balance_date:?string,
* balance_dates:list<string>,alert_count:int
* }
*/
private function shopsFromStoreReport(array $rows, array $context = []): array
{
$overallSummary = [
'count' => count($rows),
'balance' => round(array_sum(array_map(
static fn (array $row): float => (float) ($row['shop_balance'] ?? 0),
$rows,
)), 2),
];
$keyword = trim((string) request('shops_q', ''));
$platform = trim((string) request('shops_platform', ''));
$company = trim((string) request('shops_company', ''));
$rows = array_values(array_filter(
$rows,
static function (array $row) use ($keyword, $platform, $company): bool {
if ($platform !== '' && trim((string) ($row['platform'] ?? '')) !== $platform) {
return false;
}
if ($company !== '' && mb_stripos((string) ($row['company_name'] ?? ''), $company) === false) {
return false;
}
if ($keyword === '') {
return true;
}
foreach (['shop_name', 'platform', 'company_name'] as $field) {
if (mb_stripos((string) ($row[$field] ?? ''), $keyword) !== false) {
return true;
}
}
return false;
},
));
$headerColumns = $this->shopHeaderColumns();
$rows = $this->filterAndSortHeaderRows($rows, 'shops', $headerColumns);
if (! isset($headerColumns[trim((string) request('shops_sort', ''))])) {
usort($rows, static fn (array $left, array $right): int => [
(string) ($left['company_name'] ?? ''),
(string) ($left['shop_name'] ?? ''),
] <=> [
(string) ($right['company_name'] ?? ''),
(string) ($right['shop_name'] ?? ''),
]);
}
$summary = [
'count' => count($rows),
'balance' => round(array_sum(array_map(
static fn (array $row): float => (float) ($row['shop_balance'] ?? 0),
$rows,
)), 2),
'alert_count' => count(array_filter(
$rows,
static fn (array $row): bool => ($row['balance_alert'] ?? '') === '是',
)),
];
$perPage = 100;
$currentPage = Paginator::resolveCurrentPage('shops_page');
$items = array_map(
static fn (array $row): object => (object) $row,
array_slice($rows, ($currentPage - 1) * $perPage, $perPage),
);
return [
'rows' => (new LengthAwarePaginator(
$items,
count($rows),
$perPage,
$currentPage,
[
'path' => Paginator::resolveCurrentPath(),
'query' => request()->query(),
'pageName' => 'shops_page',
],
))->withQueryString(),
'summary' => $summary,
'overall_summary' => $overallSummary,
'balance_date' => $context['balance_date'] ?? null,
'balance_dates' => $context['balance_dates'] ?? [],
'alert_count' => $summary['alert_count'],
];
}
/** @return array{rows:LengthAwarePaginator,summary:array{count:int,balance:float},overall_summary:array{count:int,balance:float}} */
private function shopsFromLedgerTable(): array
{
$baseQuery = $this->db()->table('fin_shops');
$overallSummary = [
'count' => (clone $baseQuery)->count(),
'balance' => round((float) (clone $baseQuery)->sum('shop_balance'), 2),
];
$query = clone $baseQuery;
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),
'alert_count' => 0,
];
$sorted = $this->applyLedgerHeaderSort($query, 'shops', $headerColumns);
if (! $sorted) {
$query->orderBy('company_name')->orderBy('shop_name');
}
$rows = $query
->paginate(100, ['*'], 'shops_page')->withQueryString();
$rows->getCollection()->transform(static function ($row) {
$row->shop_balance_source = ($row->shop_balance_date ?? null)
? StoreReceivingBillBalanceSnapshotSynchronizer::sourceLabel(
JushuitanShopBalanceRepository::DATA_SOURCE_JUSHUITAN,
)
: '未同步';
$row->shop_balance_status = ($row->shop_balance_date ?? null) ? 'matched' : 'not_ready';
$row->reserve_standard = WithdrawalReserveStandard::amountFor(
(string) ($row->platform ?? ''),
(string) ($row->shop_name ?? ''),
);
$row->balance_alert = '否';
$row->balance_alert_message = null;
return $row;
});
return [
'rows' => $rows,
'summary' => $summary,
'overall_summary' => $overallSummary,
'balance_date' => $this->latestShopBalanceDate(),
'balance_dates' => $this->shopBalances->availableDates(
now()->startOfDay()->subDay(),
StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE,
),
'alert_count' => 0,
];
}
/**
* 综合可用金额始终按全部账户和全部店铺计算,不受账户/店铺筛选影响。
*
* @return array{bank_available:float,shop_available:float,total:float,shop_balance_date:?string}
*/
private function availableBreakdown(array $shops): array
{
$bankAvailable = round((float) $this->db()->table('fin_accounts')->sum('daily_available'), 2);
$shopAvailable = round((float) ($shops['overall_summary']['balance'] ?? 0), 2);
return [
'bank_available' => $bankAvailable,
'shop_available' => $shopAvailable,
'total' => round($bankAvailable + $shopAvailable, 2),
'shop_balance_date' => $shops['balance_date'] ?? $this->latestShopBalanceDate(),
];
}
private function flows(array $period): array
{
$query = $this->db()->table('fin_daily_flows');
$this->applyDateRange($query, 'flow_date', $period);
foreach (['flows_company' => 'company_name', 'flows_account' => 'account_short'] as $param => $column) {
$value = trim((string) request($param, ''));
if ($value !== '') {
$query->where($column, 'like', '%' . $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(?string $accountShort, array $period): array
{
$query = $this->transactionQuery();
$this->applyTransactionFilters($query, $accountShort, $period);
$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,
$this->transactionView()['account_short'],
$this->dateRange('txns'),
);
return $this->transactionSummaryMetrics($query);
}
private function applyTransactionFilters(
Builder $query,
?string $forcedAccountShort = null,
array $period = ['from' => null, 'to' => null],
): 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 . '%');
});
}
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
if ($forcedAccountShort !== null) {
$query->whereRaw($effectiveAccount . ' = ?', [$forcedAccountShort]);
} else {
$account = trim((string) request('txns_account', ''));
if ($account !== '') {
$query->whereRaw($effectiveAccount . ' LIKE ?', ['%' . $account . '%']);
}
}
$this->applyDateRange($query, 't.txn_date', $period);
$source = trim((string) request('txns_source', ''));
if ($source !== '') {
$query->where('t.source', $source);
}
$headerColumns = $this->transactionHeaderColumns();
if ($forcedAccountShort !== null) {
unset($headerColumns['effective_account_short']);
}
$this->applyLedgerHeaderFilter($query, 'transactions', $headerColumns);
}
private function transactionView(): array
{
$key = trim((string) request('txns_view', 'all'));
if (! array_key_exists($key, self::TRANSACTION_VIEWS)) {
$key = 'all';
}
return [
'key' => $key,
...self::TRANSACTION_VIEWS[$key],
];
}
private function dateRange(string $prefix): array
{
$fromKey = $prefix . '_from';
$toKey = $prefix . '_to';
$hasRange = request()->query->has($fromKey) || request()->query->has($toKey);
if ($prefix === 'flows' && ! $hasRange && request()->filled('flows_date')) {
$date = $this->normalizeDate(request('flows_date'));
return ['from' => $date, 'to' => $date];
}
if (! $hasRange) {
return [
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
];
}
$from = $this->normalizeDate(request($fromKey));
$to = $this->normalizeDate(request($toKey));
if ($from !== null && $to !== null && $from > $to) {
[$from, $to] = [$to, $from];
}
return ['from' => $from, 'to' => $to];
}
private function normalizeDate(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
if ($text === '') {
return null;
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $text);
return $date !== false && $date->format('Y-m-d') === $text ? $text : null;
}
private function applyDateRange(Builder $query, string $column, array $period): void
{
if (($period['from'] ?? null) !== null) {
$query->where($column, '>=', $period['from']);
}
if (($period['to'] ?? null) !== null) {
$query->where($column, '<=', $period['to']);
}
}
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.counterparty_info',
't.detail',
't.income',
't.expense',
't.balance',
't.approval_no',
't.payment_remark',
'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 $period): array
{
$allRows = $this->mappingPeriodQuery($period)
->get()
->map(static function ($row): array {
$data = (array) $row;
$data['mapping_status'] = trim((string) ($data['account_short'] ?? '')) !== ''
? 'mapped'
: 'unmapped';
return $data;
})
->all();
$source = trim((string) request('mapping_source', ''));
$keyword = trim((string) request('mapping_q', ''));
$unmappedOnly = request()->boolean('mapping_unmapped');
$rows = array_values(array_filter(
$allRows,
static function (array $row) use ($source, $keyword, $unmappedOnly): bool {
if ($source !== '' && (string) $row['source'] !== $source) {
return false;
}
if ($keyword !== ''
&& mb_stripos((string) $row['source_value'], $keyword) === false
&& mb_stripos((string) ($row['account_short'] ?? ''), $keyword) === false) {
return false;
}
return ! $unmappedOnly || (string) ($row['account_short'] ?? '') === '';
},
));
$total = count($rows);
$mapped = count(array_filter(
$rows,
static fn (array $row): bool => trim((string) ($row['account_short'] ?? '')) !== '',
));
$headerColumns = $this->mappingHeaderColumns();
$rows = $this->filterAndSortHeaderRows($rows, 'mapping', $headerColumns);
if (! isset($headerColumns[request('mapping_sort', '')])) {
usort($rows, static function (array $left, array $right): int {
$countCompare = (int) $right['txn_count'] <=> (int) $left['txn_count'];
return $countCompare !== 0
? $countCompare
: strnatcasecmp((string) $left['source_value'], (string) $right['source_value']);
});
}
$lastPage = max(1, (int) ceil($total / 100));
$page = min($lastPage, max(1, (int) request('mapping_page', 1)));
$paginator = new LengthAwarePaginator(
array_map(static fn (array $row): object => (object) $row, array_slice($rows, ($page - 1) * 100, 100)),
$total,
100,
$page,
['path' => request()->url(), 'query' => request()->query->all()],
);
return [
'rows' => $paginator,
'total' => $total,
'mapped' => $mapped,
'all_total' => count($allRows),
'all_mapped' => count(array_filter(
$allRows,
static fn (array $row): bool => trim((string) ($row['account_short'] ?? '')) !== '',
)),
'sources' => collect($allRows)->pluck('source')->unique()->sort()->values(),
];
}
private function mappingPeriodQuery(array $period): Builder
{
$t = $this->queryAlias('t');
$m = $this->queryAlias('m');
$query = $this->db()->table('fin_transactions as t')
->join('fin_account_map as m', function ($join): void {
$join->on('m.source', '=', 't.source')
->on('m.source_value', '=', 't.source_value');
})
->whereNotIn('t.source', [
FinanceLedgerTransactionWriter::MANUAL_SOURCE,
FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE,
])
->whereNotNull('t.source_value')
->where('t.source_value', '<>', '')
->selectRaw("MAX({$m}.id) AS id")
->selectRaw("{$t}.source AS source")
->selectRaw("{$t}.source_value AS source_value")
->selectRaw("MAX({$m}.account_short) AS account_short")
->selectRaw('COUNT(*) AS txn_count')
->selectRaw("SUM({$t}.income) AS income_sum")
->selectRaw("SUM({$t}.expense) AS expense_sum")
->groupBy('t.source', 't.source_value');
$this->applyDateRange($query, 't.txn_date', $period);
return $query;
}
private function sourceSummary(array $period): array
{
$query = $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');
$this->applyDateRange($query, 'txn_date', $period);
$rows = $query
->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 $transactionPeriod, array $flowPeriod): array
{
$transactionQuery = $this->db()->table('fin_transactions');
$this->applyDateRange($transactionQuery, 'txn_date', $transactionPeriod);
$flowQuery = $this->db()->table('fin_daily_flows');
$this->applyDateRange($flowQuery, 'flow_date', $flowPeriod);
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' => (clone $transactionQuery)->count(),
'income' => round((float) (clone $transactionQuery)->sum('income'), 2),
'expense' => round((float) (clone $transactionQuery)->sum('expense'), 2),
'flows' => (clone $flowQuery)->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('source_scope', StoreReceivingBillBalanceSnapshotSynchronizer::SOURCE_SCOPE)
->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'],
'store_manager' => ['sort' => 'shop_name', 'type' => 'text'],
'department_manager' => ['sort' => 'shop_name', 'type' => 'text'],
'payout_account' => ['sort' => 'payout_account', 'type' => 'text'],
'shop_balance_source' => ['sort' => 'shop_balance_source', 'type' => 'text'],
'shop_balance' => ['sort' => 'shop_balance', 'type' => 'number'],
'reserve_standard' => ['sort' => 'reserve_standard', 'type' => 'number'],
'balance_alert' => ['sort' => 'balance_alert', 'type' => 'enum'],
'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'],
'counterparty_info' => ['sort' => $t . '.counterparty_info', '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'],
'balance' => ['sort' => $t . '.balance', 'type' => 'number'],
'payment_remark' => ['sort' => $t . '.payment_remark', 'type' => 'text'],
'approval_no' => ['sort' => $t . '.approval_no', 'type' => 'text'],
];
}
private function mappingHeaderColumns(): array
{
$m = $this->queryAlias('m');
$mappingStatus = "CASE WHEN COALESCE({$m}.account_short, '') = '' THEN 'unmapped' ELSE 'mapped' END";
return [
'source' => ['sort' => 'source', 'value' => 'source', 'type' => 'enum'],
'source_value' => ['sort' => 'source_value', 'value' => 'source_value', 'type' => 'text'],
'txn_count' => ['sort' => 'txn_count', 'value' => 'txn_count', 'type' => 'number'],
'income_sum' => ['sort' => 'income_sum', 'value' => 'income_sum', 'type' => 'number'],
'expense_sum' => ['sort' => 'expense_sum', 'value' => 'expense_sum', 'type' => 'number'],
'account_short' => ['sort' => 'account_short', 'value' => 'account_short', 'type' => 'text'],
'mapping_status' => [
'sort' => $mappingStatus,
'value' => 'mapping_status',
'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;
}
}