810 lines
30 KiB
PHP
810 lines
30 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\View;
|
||
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
|
||
/**
|
||
* 店铺收支明细查询。
|
||
*
|
||
* 汇总与余额始终按账户全期间计算,方向和来源类型只控制明细展示及导出范围。
|
||
*/
|
||
class ShopFlowQuery
|
||
{
|
||
private const PER_PAGE = 200;
|
||
|
||
private const DIRECTIONS = ['all', 'income', 'expense'];
|
||
|
||
private const SOURCE_TYPES = ['all', 'shop', 'other', 'dingtalk'];
|
||
|
||
// 快照数据使用 shop,现行店铺账单同步使用 shop_bill,两者都是提现金额口径。
|
||
private const WITHDRAW_SOURCES = ['shop', 'shop_bill'];
|
||
|
||
private const SOURCE_LABELS = [
|
||
'dingtalk' => '钉钉审批',
|
||
'shop' => '店铺提现',
|
||
'shop_bill' => '店铺账单提现',
|
||
'payable' => '供应商应付',
|
||
'store' => '店铺开店',
|
||
'workorder' => '聚工单',
|
||
'manual' => '手工录入',
|
||
];
|
||
|
||
public function pageData(?Request $request = null): array
|
||
{
|
||
$request ??= request();
|
||
$context = $this->context($request);
|
||
$filters = $context['filters'];
|
||
$companies = $context['companies'];
|
||
$shopRows = $context['shops'];
|
||
$accountRows = $context['accounts'];
|
||
$selection = $context['selection'];
|
||
$filters['company'] = $selection['company'];
|
||
$filters['shop'] = $selection['shop']['shop_name'] ?? '';
|
||
$filters['account'] = $selection['account_short'];
|
||
|
||
$summary = [
|
||
'income_count' => 0,
|
||
'income' => 0.0,
|
||
'withdraw_count' => 0,
|
||
'pending_count' => 0,
|
||
'expense_count' => 0,
|
||
'expense' => 0.0,
|
||
];
|
||
$rows = [];
|
||
$openingBalance = null;
|
||
$closingBalance = null;
|
||
|
||
if ($selection['account_short'] !== '') {
|
||
$transactions = $this->transactions(
|
||
$selection['account_short'],
|
||
$filters['start'],
|
||
$filters['end'],
|
||
);
|
||
$accountMappings = $this->accountMappings($transactions);
|
||
$relatedShops = $selection['related_shops'];
|
||
$runningBalance = $this->openingBalance(
|
||
$selection['account_short'],
|
||
$filters['start'],
|
||
);
|
||
$openingBalance = $runningBalance;
|
||
|
||
foreach ($transactions as $transaction) {
|
||
$income = round((float) ($transaction->income ?? 0), 2);
|
||
$expense = round((float) ($transaction->expense ?? 0), 2);
|
||
$source = trim((string) ($transaction->source ?? ''));
|
||
$sourceValue = trim((string) ($transaction->source_value ?? ''));
|
||
$isIncome = $income > 0;
|
||
$isDingTalkExpense = $source === 'dingtalk' && $expense > 0;
|
||
if (! $isIncome && ! $isDingTalkExpense) {
|
||
continue;
|
||
}
|
||
|
||
if ($runningBalance !== null) {
|
||
$runningBalance = round($runningBalance + $income - ($isDingTalkExpense ? $expense : 0), 2);
|
||
}
|
||
|
||
$type = $this->rowType($isIncome, $source, $sourceValue);
|
||
$shopName = $this->attributeShop(
|
||
$source,
|
||
$sourceValue,
|
||
(string) ($transaction->detail ?? ''),
|
||
(string) ($transaction->approval_no ?? ''),
|
||
$selection['account_short'],
|
||
$relatedShops,
|
||
$accountMappings,
|
||
);
|
||
|
||
if ($isIncome) {
|
||
$summary['income_count']++;
|
||
$summary['income'] = round($summary['income'] + $income, 2);
|
||
if (in_array($source, self::WITHDRAW_SOURCES, true)) {
|
||
$summary['withdraw_count']++;
|
||
if ($sourceValue === '') {
|
||
$summary['pending_count']++;
|
||
}
|
||
}
|
||
}
|
||
if ($isDingTalkExpense) {
|
||
$summary['expense_count']++;
|
||
$summary['expense'] = round($summary['expense'] + $expense, 2);
|
||
}
|
||
|
||
$rows[] = [
|
||
'id' => (int) $transaction->id,
|
||
'txn_date' => substr((string) $transaction->txn_date, 0, 10),
|
||
'direction' => $isIncome ? 'income' : 'expense',
|
||
'type_key' => $type['key'],
|
||
'type_label' => $type['label'],
|
||
'match_status' => $type['match_status'],
|
||
'detail' => trim((string) ($transaction->detail ?? '')),
|
||
'process_type' => trim((string) ($transaction->process_type ?? '')),
|
||
'source' => $source,
|
||
'source_value' => $sourceValue,
|
||
'approval_no' => trim((string) ($transaction->approval_no ?? '')),
|
||
'shop_name' => $shopName,
|
||
'income' => $isIncome ? $income : 0.0,
|
||
'expense' => $isDingTalkExpense ? $expense : 0.0,
|
||
'balance' => $runningBalance,
|
||
];
|
||
}
|
||
|
||
$closingBalance = $this->closingBalance($selection['account_short'], $filters['end']);
|
||
}
|
||
|
||
$filteredRows = array_values(array_filter(
|
||
$rows,
|
||
static fn (array $row): bool => self::matchesDisplayFilter($row, $filters),
|
||
));
|
||
usort($filteredRows, static fn (array $left, array $right): int => [
|
||
$right['txn_date'],
|
||
$right['id'],
|
||
] <=> [
|
||
$left['txn_date'],
|
||
$left['id'],
|
||
]);
|
||
|
||
$currentPage = max(1, (int) $request->query('shop_flows_page', 1));
|
||
$pageRows = array_slice(
|
||
$filteredRows,
|
||
($currentPage - 1) * self::PER_PAGE,
|
||
self::PER_PAGE,
|
||
);
|
||
$paginator = new LengthAwarePaginator(
|
||
$pageRows,
|
||
count($filteredRows),
|
||
self::PER_PAGE,
|
||
$currentPage,
|
||
[
|
||
'path' => $request->url(),
|
||
'query' => $request->query(),
|
||
'pageName' => 'shop_flows_page',
|
||
],
|
||
);
|
||
|
||
$balanceCheck = $this->balanceCheck(
|
||
$openingBalance,
|
||
$summary['income'],
|
||
$summary['expense'],
|
||
$closingBalance,
|
||
);
|
||
|
||
return [
|
||
'shopFlowFilters' => $filters,
|
||
'shopFlowCompanies' => $companies['options'],
|
||
'shopFlowShops' => array_values($shopRows),
|
||
'shopFlowAccounts' => array_values($accountRows),
|
||
'shopFlowSelectedShop' => $selection['shop'],
|
||
'shopFlowSelectedAccount' => $selection['account'],
|
||
'shopFlowSelectedAccountShort' => $selection['account_short'],
|
||
'shopFlowRelatedShops' => $selection['related_shops'],
|
||
'shopFlowSummary' => $summary,
|
||
'shopFlowRows' => $paginator,
|
||
'shopFlowBalance' => $balanceCheck,
|
||
'shopFlowPageUrl' => $this->pageUrl(),
|
||
'shopFlowResetUrl' => $this->pageUrl(),
|
||
'shopFlowUnlockUrl' => $this->unlockUrl($selection, $filters),
|
||
'shopFlowExportUrl' => $this->exportUrl($filters),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 按库游标流式导出当前筛选结果,避免全量结果驻留内存。
|
||
*/
|
||
public function export(Request $request): StreamedResponse
|
||
{
|
||
$context = $this->context($request);
|
||
$filters = $context['filters'];
|
||
$selection = $context['selection'];
|
||
$fileName = '店铺收支明细-' . str_replace('-', '', $filters['start'])
|
||
. '-' . str_replace('-', '', $filters['end']) . '.csv';
|
||
|
||
return response()->streamDownload(function () use ($filters, $selection): void {
|
||
$output = fopen('php://output', 'w');
|
||
fwrite($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||
fputcsv($output, ['日期', '方向·类型', '摘要', '归属店铺', '来源单据', '收入', '支出', '余额']);
|
||
|
||
if ($selection['account_short'] === '') {
|
||
fclose($output);
|
||
|
||
return;
|
||
}
|
||
|
||
$relatedShops = $selection['related_shops'];
|
||
$accountMappings = $this->accountMappingsForPeriod(
|
||
$selection['account_short'],
|
||
$filters['start'],
|
||
$filters['end'],
|
||
);
|
||
$balance = $this->openingBalance($selection['account_short'], $filters['start']);
|
||
$rows = $this->transactionQuery(
|
||
$selection['account_short'],
|
||
$filters['start'],
|
||
$filters['end'],
|
||
)
|
||
->orderBy('t.txn_date')
|
||
->orderBy('t.id')
|
||
->select($this->transactionColumns())
|
||
->cursor();
|
||
|
||
foreach ($rows as $transaction) {
|
||
$income = round((float) ($transaction->income ?? 0), 2);
|
||
$expense = round((float) ($transaction->expense ?? 0), 2);
|
||
$source = trim((string) ($transaction->source ?? ''));
|
||
$sourceValue = trim((string) ($transaction->source_value ?? ''));
|
||
$isIncome = $income > 0;
|
||
$isDingTalkExpense = $source === 'dingtalk' && $expense > 0;
|
||
if (! $isIncome && ! $isDingTalkExpense) {
|
||
continue;
|
||
}
|
||
|
||
if ($balance !== null) {
|
||
$balance = round($balance + $income - ($isDingTalkExpense ? $expense : 0), 2);
|
||
}
|
||
|
||
$type = $this->rowType($isIncome, $source, $sourceValue);
|
||
$shopName = $this->attributeShop(
|
||
$source,
|
||
$sourceValue,
|
||
(string) ($transaction->detail ?? ''),
|
||
(string) ($transaction->approval_no ?? ''),
|
||
$selection['account_short'],
|
||
$relatedShops,
|
||
$accountMappings,
|
||
);
|
||
$row = [
|
||
'direction' => $isIncome ? 'income' : 'expense',
|
||
'source' => $source,
|
||
];
|
||
if (! self::matchesDisplayFilter($row, $filters)) {
|
||
continue;
|
||
}
|
||
|
||
$typeText = $type['match_status'] === ''
|
||
? $type['label']
|
||
: $type['label'] . '(' . $type['match_status'] . ')';
|
||
fputcsv($output, [
|
||
substr((string) $transaction->txn_date, 0, 10),
|
||
$this->csvValue($typeText),
|
||
$this->csvValue((string) ($transaction->detail ?? '')),
|
||
$this->csvValue($shopName),
|
||
$this->csvValue((string) ($transaction->approval_no ?? '')),
|
||
$isIncome ? '¥' . number_format($income, 2) : '',
|
||
$isDingTalkExpense ? '¥' . number_format($expense, 2) : '',
|
||
$balance === null ? '' : '¥' . number_format($balance, 2),
|
||
]);
|
||
}
|
||
|
||
fclose($output);
|
||
}, $fileName, [
|
||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||
'Cache-Control' => 'private, no-store',
|
||
]);
|
||
}
|
||
|
||
private function context(Request $request): array
|
||
{
|
||
$filters = $this->filters($request);
|
||
$companies = $this->companies();
|
||
$shops = $this->shopRows();
|
||
$accounts = $this->accountRows();
|
||
|
||
return [
|
||
'filters' => $filters,
|
||
'companies' => $companies,
|
||
'shops' => $shops,
|
||
'accounts' => $accounts,
|
||
'selection' => $this->selection($filters, $companies, $shops, $accounts),
|
||
];
|
||
}
|
||
|
||
private function filters(Request $request): array
|
||
{
|
||
$start = $this->normalizeDate((string) $request->query('start', ''));
|
||
$end = $this->normalizeDate((string) $request->query('end', ''));
|
||
$start ??= now()->startOfMonth()->toDateString();
|
||
$end ??= now()->toDateString();
|
||
if ($start > $end) {
|
||
[$start, $end] = [$end, $start];
|
||
}
|
||
|
||
$direction = trim((string) $request->query('direction', 'all'));
|
||
$sourceType = trim((string) $request->query('source_type', 'all'));
|
||
|
||
return [
|
||
'company' => trim((string) $request->query('company', '')),
|
||
'shop' => trim((string) $request->query('shop', '')),
|
||
'account' => trim((string) $request->query('account', '')),
|
||
'start' => $start,
|
||
'end' => $end,
|
||
'direction' => in_array($direction, self::DIRECTIONS, true) ? $direction : 'all',
|
||
'source_type' => in_array($sourceType, self::SOURCE_TYPES, true) ? $sourceType : 'all',
|
||
];
|
||
}
|
||
|
||
private function companies(): array
|
||
{
|
||
$companyNames = $this->db()->table('fin_companies')
|
||
->orderBy('name')
|
||
->pluck('name')
|
||
->merge(
|
||
$this->db()->table('fin_shops')
|
||
->whereNotNull('company_name')
|
||
->where('company_name', '<>', '')
|
||
->distinct()
|
||
->pluck('company_name'),
|
||
)
|
||
->merge(
|
||
$this->db()->table('fin_accounts')
|
||
->whereNotNull('company_name')
|
||
->where('company_name', '<>', '')
|
||
->distinct()
|
||
->pluck('company_name'),
|
||
);
|
||
|
||
$options = $companyNames
|
||
->map(static fn (mixed $name): string => trim((string) $name))
|
||
->filter()
|
||
->unique()
|
||
->sort(static fn (string $left, string $right): int => strnatcasecmp($left, $right))
|
||
->values()
|
||
->all();
|
||
|
||
return ['options' => $options];
|
||
}
|
||
|
||
private function shopRows(): array
|
||
{
|
||
return $this->db()->table('fin_shops')
|
||
->orderBy('company_name')
|
||
->orderBy('shop_name')
|
||
->get([
|
||
'shop_name',
|
||
'platform',
|
||
'company_name',
|
||
'account_short',
|
||
'status',
|
||
'payout_account',
|
||
])
|
||
->map(static function ($row): array {
|
||
return [
|
||
'shop_name' => trim((string) $row->shop_name),
|
||
'platform' => trim((string) ($row->platform ?? '')),
|
||
'company_name' => trim((string) ($row->company_name ?? '')),
|
||
'account_short' => trim((string) ($row->account_short ?? '')),
|
||
'status' => trim((string) ($row->status ?? '')),
|
||
'payout_account' => trim((string) ($row->payout_account ?? '')),
|
||
];
|
||
})
|
||
->filter(static fn (array $row): bool => $row['shop_name'] !== '')
|
||
->keyBy('shop_name')
|
||
->all();
|
||
}
|
||
|
||
private function accountRows(): array
|
||
{
|
||
return $this->db()->table('fin_accounts')
|
||
->orderBy('company_name')
|
||
->orderBy('account_short')
|
||
->orderBy('id')
|
||
->get([
|
||
'account_short',
|
||
'company_name',
|
||
'account_no',
|
||
'bank_name',
|
||
'account_last4',
|
||
'current_balance',
|
||
'owner',
|
||
])
|
||
->filter(static fn ($row): bool => trim((string) $row->account_short) !== '')
|
||
->groupBy(static fn ($row): string => trim((string) $row->account_short))
|
||
->map(static function ($rows, string $accountShort): array {
|
||
$row = $rows->first();
|
||
|
||
return [
|
||
'account_short' => $accountShort,
|
||
'company_name' => trim((string) ($row->company_name ?? '')),
|
||
'account_no' => trim((string) ($row->account_no ?? '')),
|
||
'bank_name' => trim((string) ($row->bank_name ?? '')),
|
||
'account_last4' => trim((string) ($row->account_last4 ?? '')),
|
||
'current_balance' => round((float) ($row->current_balance ?? 0), 2),
|
||
'owner' => trim((string) ($row->owner ?? '')),
|
||
];
|
||
})
|
||
->all();
|
||
}
|
||
|
||
private function selection(
|
||
array $filters,
|
||
array $companies,
|
||
array $shops,
|
||
array $accounts,
|
||
): array {
|
||
$company = in_array($filters['company'], $companies['options'], true)
|
||
? $filters['company']
|
||
: '';
|
||
$shop = $shops[$filters['shop']] ?? null;
|
||
$accountShort = '';
|
||
|
||
if ($shop !== null) {
|
||
$company = $shop['company_name'] !== '' ? $shop['company_name'] : $company;
|
||
$accountShort = $shop['account_short'];
|
||
} elseif ($filters['account'] !== '') {
|
||
$candidate = $accounts[$filters['account']] ?? null;
|
||
if ($candidate !== null
|
||
&& ($company === '' || $candidate['company_name'] === $company)) {
|
||
$accountShort = $candidate['account_short'];
|
||
}
|
||
}
|
||
|
||
$account = $accountShort !== '' ? ($accounts[$accountShort] ?? null) : null;
|
||
$relatedShops = $accountShort === ''
|
||
? []
|
||
: array_values(array_filter(
|
||
$shops,
|
||
static fn (array $row): bool => $row['account_short'] === $accountShort,
|
||
));
|
||
|
||
return [
|
||
'company' => $company,
|
||
'shop' => $shop,
|
||
'account' => $account,
|
||
'account_short' => $accountShort,
|
||
'related_shops' => $relatedShops,
|
||
];
|
||
}
|
||
|
||
private function transactions(string $accountShort, string $start, string $end): array
|
||
{
|
||
return $this->transactionQuery($accountShort, $start, $end)
|
||
->orderBy('t.txn_date')
|
||
->orderBy('t.id')
|
||
->get($this->transactionColumns())
|
||
->all();
|
||
}
|
||
|
||
private function transactionQuery(string $accountShort, string $start, string $end)
|
||
{
|
||
// 钉钉付款的原始 account_short 常为空,按账户映射解析;无映射时保留原值兜底。
|
||
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');
|
||
})
|
||
->where(function ($query) use ($accountShort): void {
|
||
$query->where(function ($mapped) use ($accountShort): void {
|
||
$mapped->whereNotNull('m.account_short')
|
||
->where('m.account_short', '<>', '')
|
||
->where('m.account_short', $accountShort);
|
||
})->orWhere(function ($direct) use ($accountShort): void {
|
||
$direct->where(function ($mapping): void {
|
||
$mapping->whereNull('m.account_short')->orWhere('m.account_short', '');
|
||
})->where('t.account_short', $accountShort);
|
||
});
|
||
})
|
||
->whereBetween('t.txn_date', [$start, $end])
|
||
->where(function ($query): void {
|
||
$query->where('t.income', '>', 0)
|
||
->orWhere(function ($expense): void {
|
||
$expense->where('t.source', 'dingtalk')->where('t.expense', '>', 0);
|
||
});
|
||
});
|
||
}
|
||
|
||
private function transactionColumns(): array
|
||
{
|
||
return [
|
||
't.id',
|
||
't.txn_date',
|
||
't.detail',
|
||
't.income',
|
||
't.expense',
|
||
't.approval_no',
|
||
't.source',
|
||
't.source_value',
|
||
't.process_type',
|
||
];
|
||
}
|
||
|
||
private function accountMappings(array $transactions): array
|
||
{
|
||
$sourceValues = [];
|
||
$sources = [];
|
||
foreach ($transactions as $transaction) {
|
||
$source = trim((string) ($transaction->source ?? ''));
|
||
$sourceValue = trim((string) ($transaction->source_value ?? ''));
|
||
if ($source === '' || $sourceValue === '') {
|
||
continue;
|
||
}
|
||
$sources[$source] = true;
|
||
$sourceValues[$sourceValue] = true;
|
||
}
|
||
|
||
if ($sources === [] || $sourceValues === []) {
|
||
return [];
|
||
}
|
||
|
||
$mappings = [];
|
||
$rows = $this->db()->table('fin_account_map')
|
||
->whereIn('source', array_keys($sources))
|
||
->whereIn('source_value', array_keys($sourceValues))
|
||
->get(['source', 'source_value', 'account_short']);
|
||
foreach ($rows as $row) {
|
||
$mappings[$row->source . "\0" . $row->source_value] = trim((string) ($row->account_short ?? ''));
|
||
}
|
||
|
||
return $mappings;
|
||
}
|
||
|
||
private function accountMappingsForPeriod(string $accountShort, string $start, string $end): array
|
||
{
|
||
$transactions = $this->transactionQuery($accountShort, $start, $end)
|
||
->reorder()
|
||
->select('t.source', 't.source_value')
|
||
->distinct()
|
||
->get()
|
||
->all();
|
||
|
||
return $this->accountMappings($transactions);
|
||
}
|
||
|
||
private function openingBalance(string $accountShort, string $start): ?float
|
||
{
|
||
$previousDate = date('Y-m-d', strtotime($start . ' -1 day'));
|
||
$closing = $this->db()->table('fin_daily_flows')
|
||
->where('account_short', $accountShort)
|
||
->where('flow_date', $previousDate)
|
||
->value('closing_balance');
|
||
if ($closing !== null) {
|
||
return round((float) $closing, 2);
|
||
}
|
||
|
||
$opening = $this->db()->table('fin_account_opening')
|
||
->where('account_short', $accountShort)
|
||
->where('opening_date', '<=', $start)
|
||
->orderByDesc('opening_date')
|
||
->orderByDesc('id')
|
||
->value('opening_balance');
|
||
|
||
return $opening === null ? null : round((float) $opening, 2);
|
||
}
|
||
|
||
private function closingBalance(string $accountShort, string $end): ?float
|
||
{
|
||
$closing = $this->db()->table('fin_daily_flows')
|
||
->where('account_short', $accountShort)
|
||
->where('flow_date', $end)
|
||
->value('closing_balance');
|
||
|
||
return $closing === null ? null : round((float) $closing, 2);
|
||
}
|
||
|
||
private function rowType(bool $isIncome, string $source, string $sourceValue): array
|
||
{
|
||
if ($isIncome && in_array($source, self::WITHDRAW_SOURCES, true)) {
|
||
return [
|
||
'key' => 'income_withdraw',
|
||
'label' => '收入·提现',
|
||
'match_status' => $sourceValue === '' ? '待匹配提现单' : '已匹配提现单',
|
||
];
|
||
}
|
||
if ($isIncome) {
|
||
return [
|
||
'key' => 'income_other',
|
||
'label' => '收入·其他',
|
||
'match_status' => '',
|
||
];
|
||
}
|
||
|
||
return [
|
||
'key' => 'expense_dingtalk',
|
||
'label' => '支出·钉钉流程',
|
||
'match_status' => '',
|
||
];
|
||
}
|
||
|
||
private function attributeShop(
|
||
string $source,
|
||
string $sourceValue,
|
||
string $detail,
|
||
string $approvalNo,
|
||
string $accountShort,
|
||
array $relatedShops,
|
||
array $accountMappings,
|
||
): string {
|
||
$byNormalizedName = [];
|
||
foreach ($relatedShops as $shop) {
|
||
$byNormalizedName[$this->normalizeShopName($shop['shop_name'])] = $shop['shop_name'];
|
||
}
|
||
|
||
$normalizedSourceValue = $this->normalizeShopName($sourceValue);
|
||
if ($normalizedSourceValue !== '' && isset($byNormalizedName[$normalizedSourceValue])) {
|
||
return $byNormalizedName[$normalizedSourceValue];
|
||
}
|
||
|
||
$normalizedApproval = $this->normalizeShopName($approvalNo);
|
||
if ($normalizedApproval !== '' && isset($byNormalizedName[$normalizedApproval])) {
|
||
return $byNormalizedName[$normalizedApproval];
|
||
}
|
||
|
||
$normalizedDetail = $this->normalizeShopName($detail);
|
||
$detailMatches = [];
|
||
foreach ($byNormalizedName as $normalizedName => $shopName) {
|
||
if ($normalizedName !== '' && str_contains($normalizedDetail, $normalizedName)) {
|
||
$detailMatches[] = $shopName;
|
||
}
|
||
}
|
||
if (count($detailMatches) === 1) {
|
||
return $detailMatches[0];
|
||
}
|
||
|
||
if ($sourceValue !== '') {
|
||
$mappedAccount = $accountMappings[$source . "\0" . $sourceValue] ?? '';
|
||
if ($mappedAccount === $accountShort && count($relatedShops) === 1) {
|
||
return $relatedShops[0]['shop_name'];
|
||
}
|
||
}
|
||
|
||
return count($relatedShops) === 1 ? $relatedShops[0]['shop_name'] : '';
|
||
}
|
||
|
||
private function normalizeShopName(string $value): string
|
||
{
|
||
$value = mb_strtolower(trim($value), 'UTF-8');
|
||
|
||
return preg_replace('/[\s\x{3000}\-_\/\\\\·•()()【】\[\]]+/u', '', $value) ?? $value;
|
||
}
|
||
|
||
private static function matchesDisplayFilter(array $row, array $filters): bool
|
||
{
|
||
if ($filters['direction'] === 'income' && $row['direction'] !== 'income') {
|
||
return false;
|
||
}
|
||
if ($filters['direction'] === 'expense' && $row['direction'] !== 'expense') {
|
||
return false;
|
||
}
|
||
|
||
return match ($filters['source_type']) {
|
||
'shop' => $row['direction'] === 'income'
|
||
&& in_array($row['source'], self::WITHDRAW_SOURCES, true),
|
||
'other' => $row['direction'] === 'income'
|
||
&& ! in_array($row['source'], [...self::WITHDRAW_SOURCES, 'dingtalk'], true),
|
||
'dingtalk' => $row['direction'] === 'expense' && $row['source'] === 'dingtalk',
|
||
default => true,
|
||
};
|
||
}
|
||
|
||
private function balanceCheck(
|
||
?float $opening,
|
||
float $income,
|
||
float $expense,
|
||
?float $closing,
|
||
): array {
|
||
$calculated = $opening === null
|
||
? null
|
||
: round($opening + $income - $expense, 2);
|
||
|
||
if ($opening === null) {
|
||
return [
|
||
'opening' => null,
|
||
'closing' => $closing,
|
||
'calculated_closing' => null,
|
||
'difference' => null,
|
||
'status' => 'opening_missing',
|
||
'message' => '期初余额未维护,无法完成余额恒等式自检。',
|
||
];
|
||
}
|
||
if ($closing === null) {
|
||
return [
|
||
'opening' => $opening,
|
||
'closing' => null,
|
||
'calculated_closing' => $calculated,
|
||
'difference' => null,
|
||
'status' => 'closing_missing',
|
||
'message' => '期间末日没有每日余额快照,期初与本期收支已汇总,暂无法完成余额自检。',
|
||
];
|
||
}
|
||
|
||
$difference = round($calculated - $closing, 2);
|
||
if (abs($difference) > 0.01) {
|
||
return [
|
||
'opening' => $opening,
|
||
'closing' => $closing,
|
||
'calculated_closing' => $calculated,
|
||
'difference' => $difference,
|
||
'status' => 'mismatch',
|
||
'message' => '余额恒等式不一致,差额 ' . number_format($difference, 2) . ' 元,请核对流水来源与每日余额。',
|
||
];
|
||
}
|
||
|
||
return [
|
||
'opening' => $opening,
|
||
'closing' => $closing,
|
||
'calculated_closing' => $calculated,
|
||
'difference' => 0.0,
|
||
'status' => 'matched',
|
||
'message' => '余额恒等式校验通过:期初 + 本期收入 − 本期支出 = 期末。',
|
||
];
|
||
}
|
||
|
||
private function unlockUrl(array $selection, array $filters): string
|
||
{
|
||
if (($selection['shop'] ?? null) === null || $selection['account_short'] === '') {
|
||
return '';
|
||
}
|
||
|
||
return $this->pageUrl([
|
||
'company' => $selection['company'],
|
||
'account' => $selection['account_short'],
|
||
'start' => $filters['start'],
|
||
'end' => $filters['end'],
|
||
'direction' => $filters['direction'],
|
||
'source_type' => $filters['source_type'],
|
||
]);
|
||
}
|
||
|
||
private function pageUrl(array $query = []): string
|
||
{
|
||
$url = route('business.pages.show', [
|
||
'section' => 'financial-operations',
|
||
'page' => 'shop-flows',
|
||
]);
|
||
|
||
return $query === [] ? $url : $url . '?' . http_build_query(array_filter(
|
||
$query,
|
||
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||
));
|
||
}
|
||
|
||
private function exportUrl(array $filters): string
|
||
{
|
||
$url = route('business.pages.export', [
|
||
'section' => 'financial-operations',
|
||
'page' => 'shop-flows',
|
||
'tab' => 'shop-flows',
|
||
]);
|
||
|
||
return $url . '?' . http_build_query(array_filter([
|
||
'company' => $filters['company'],
|
||
'shop' => $filters['shop'],
|
||
'account' => $filters['account'],
|
||
'start' => $filters['start'],
|
||
'end' => $filters['end'],
|
||
'direction' => $filters['direction'],
|
||
'source_type' => $filters['source_type'],
|
||
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
||
}
|
||
|
||
private function csvValue(string $value): string
|
||
{
|
||
if (preg_match('/^[=+\-@]/', $value) === 1) {
|
||
return "'" . $value;
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private function normalizeDate(string $value): ?string
|
||
{
|
||
$value = trim($value);
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||
if (! checkdate($month, $day, $year)) {
|
||
return null;
|
||
}
|
||
|
||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||
}
|
||
|
||
private function db()
|
||
{
|
||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
}
|
||
}
|