qiuxiang 78115f38d2 feat: 新增店铺收支明细页
新增店铺收支明细页,支持店铺/账户筛选、余额自检、整卡关联店铺与流式 CSV 导出。
2026-11-03 16:07:13 +08:00

810 lines
30 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 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'));
}
}