feat: 隔离朱俊祥月度账单账户

This commit is contained in:
shanshi 2026-11-06 16:55:10 +08:00
parent e0f12d7a2b
commit b0fdeb8893
21 changed files with 691 additions and 96 deletions

View File

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@ -14,19 +15,23 @@ class VerifyFinanceLedger extends Command
public function handle(): int
{
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$accounts = (int) $db->table('fin_accounts')->count();
$accountQuery = FinanceLedgerIndependentAccounts::exclude($db->table('fin_accounts'));
$openingQuery = FinanceLedgerIndependentAccounts::exclude($db->table('fin_account_opening'));
$transactionQuery = FinanceLedgerIndependentAccounts::exclude($db->table('fin_transactions'));
$mappingQuery = FinanceLedgerIndependentAccounts::exclude($db->table('fin_account_map'));
$accounts = (int) (clone $accountQuery)->count();
$shops = (int) $db->table('fin_shops')->count();
$companies = (int) $db->table('fin_companies')->count();
$openingRows = (int) $db->table('fin_account_opening')->count();
$transactions = (int) $db->table('fin_transactions')->count();
$openingRows = (int) (clone $openingQuery)->count();
$transactions = (int) (clone $transactionQuery)->count();
$flows = (int) $db->table('fin_daily_flows')->count();
$maps = (int) $db->table('fin_account_map')->count();
$mapped = (int) $db->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count();
$maps = (int) (clone $mappingQuery)->count();
$mapped = (int) (clone $mappingQuery)->whereNotNull('account_short')->where('account_short', '<>', '')->count();
$accountBalance = round((float) $db->table('fin_accounts')->sum('current_balance'), 2);
$openingSum = round((float) $db->table('fin_account_opening')->sum('opening_balance'), 2);
$income = round((float) $db->table('fin_transactions')->sum('income'), 2);
$expense = round((float) $db->table('fin_transactions')->sum('expense'), 2);
$accountBalance = round((float) (clone $accountQuery)->sum('current_balance'), 2);
$openingSum = round((float) (clone $openingQuery)->sum('opening_balance'), 2);
$income = round((float) (clone $transactionQuery)->sum('income'), 2);
$expense = round((float) (clone $transactionQuery)->sum('expense'), 2);
$flowOpening = round((float) $db->table('fin_daily_flows')->sum('opening_balance'), 2);
$flowInflow = round((float) $db->table('fin_daily_flows')->sum('inflow'), 2);
$flowOutflow = round((float) $db->table('fin_daily_flows')->sum('outflow'), 2);

View File

@ -6,6 +6,7 @@ use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use App\Utilities\FinanceLedgerAccess;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@ -84,10 +85,11 @@ class FinanceLedger extends Controller
if ($accountType !== '') {
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$allowedTypes = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotIn('account_short', FinanceLedgerIndependentAccounts::names())
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all(),
);
@ -110,7 +112,7 @@ class FinanceLedger extends Controller
'ok' => true,
'message' => $message,
'account' => $result,
'total_accounts' => $connection->table('fin_accounts')->count(),
'total_accounts' => FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))->count(),
'total_companies' => $connection->table('fin_companies')->count(),
], 201, ['Cache-Control' => 'private, no-store']);
}
@ -180,7 +182,10 @@ class FinanceLedger extends Controller
'pending_owner' => trim((string) $request->query('pending_owner', '')),
];
$query = $connection->table('fin_accounts as accounts')
$query = FinanceLedgerIndependentAccounts::exclude(
$connection->table('fin_accounts as accounts'),
'accounts.account_short',
)
->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
@ -304,10 +309,11 @@ class FinanceLedger extends Controller
});
$typeOptions = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotIn('account_short', FinanceLedgerIndependentAccounts::names())
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
);
@ -385,7 +391,10 @@ class FinanceLedger extends Controller
$headerFilterValue = '';
}
$query = $connection->table('fin_accounts as accounts')
$query = FinanceLedgerIndependentAccounts::exclude(
$connection->table('fin_accounts as accounts'),
'accounts.account_short',
)
->leftJoin('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_keep_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
@ -1114,9 +1123,12 @@ class FinanceLedger extends Controller
abort_unless(FinanceLedgerAccess::canEditTransactions(user()), 403, '当前账号没有新增手工动账明细权限。');
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
$isIndependent = FinanceLedgerIndependentAccounts::contains($request->input('account_short'));
$data = $request->validate([
'idempotency_key' => ['required', 'uuid'],
'txn_date' => ['required', 'date', 'after_or_equal:' . $openingDate],
'txn_date' => $isIndependent
? ['required', 'date']
: ['required', 'date', 'after_or_equal:' . $openingDate],
'account_short' => ['required', 'string', 'max:100'],
'counterparty_info' => ['nullable', 'string', 'max:255'],
'detail' => ['required', 'string', 'max:4000'],
@ -1210,6 +1222,8 @@ class FinanceLedger extends Controller
->where('account_short', $accountShort)
->whereDate('flow_date', (string) $row->txn_date)
->first(['flow_date', 'opening_balance', 'inflow', 'outflow', 'closing_balance']);
$summary = $pageData->transactionSummary();
$isIndependent = FinanceLedgerIndependentAccounts::contains($accountShort);
return response()->json([
'ok' => true,
'created' => $inserted,
@ -1231,7 +1245,9 @@ class FinanceLedger extends Controller
'account' => [
'account_short' => (string) ($accountInfo->account_short ?? $accountShort),
'company_name' => (string) ($accountInfo->company_name ?? ''),
'current_balance' => round((float) ($accountInfo->current_balance ?? 0), 2),
'current_balance' => $isIndependent
? $summary['current_balance']
: round((float) ($accountInfo->current_balance ?? 0), 2),
],
'daily_flow' => $flow === null ? null : [
'flow_date' => (string) $flow->flow_date,
@ -1240,8 +1256,8 @@ class FinanceLedger extends Controller
'outflow' => round((float) $flow->outflow, 2),
'closing_balance' => round((float) $flow->closing_balance, 2),
],
'summary' => $pageData->transactionSummary(),
'total_transactions' => $connection->table('fin_transactions')->count(),
'summary' => $summary,
'total_transactions' => $pageData->globalTransactionCount(),
'next_idempotency_key' => (string) Str::uuid(),
], $inserted ? 201 : 200, ['Cache-Control' => 'private, no-store']);
}
@ -1535,8 +1551,11 @@ class FinanceLedger extends Controller
abort_unless(FinanceLedgerAccess::canEditTransactions(user()), 403, '当前账号没有编辑手工动账明细权限。');
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
$isIndependent = FinanceLedgerIndependentAccounts::contains($request->input('account_short'));
$data = $request->validate([
'txn_date' => ['required', 'date', 'after_or_equal:' . $openingDate],
'txn_date' => $isIndependent
? ['required', 'date']
: ['required', 'date', 'after_or_equal:' . $openingDate],
'account_short' => ['required', 'string', 'max:100'],
'counterparty_info' => ['nullable', 'string', 'max:255'],
'detail' => ['required', 'string', 'max:4000'],
@ -1550,7 +1569,9 @@ class FinanceLedger extends Controller
return response()->json([
'ok' => true,
'transaction' => $editor->update($id, $data),
'message' => '手工明细已保存并重新计算资金流水。',
'message' => FinanceLedgerIndependentAccounts::contains($data['account_short'])
? '手工明细已保存到独立账单。'
: '手工明细已保存并重新计算资金流水。',
], 200, ['Cache-Control' => 'private, no-store']);
}
@ -1561,12 +1582,14 @@ class FinanceLedger extends Controller
{
abort_unless(FinanceLedgerAccess::canEditTransactions(user()), 403, '当前账号没有删除手工动账明细权限。');
$editor->delete($id);
$deleted = $editor->delete($id);
return response()->json([
'ok' => true,
'deleted' => true,
'message' => '手工明细已删除并重新计算资金流水。',
'message' => FinanceLedgerIndependentAccounts::contains($deleted['account_short'] ?? null)
? '手工明细已从独立账单删除。'
: '手工明细已删除并重新计算资金流水。',
], 200, ['Cache-Control' => 'private, no-store']);
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Utilities;
use Illuminate\Database\Query\Builder;
/**
* 独立月度账单账户。
*
* 这些账户的明细只在动账明细专属页展示和汇总,不进入账户总表、每日资金流水、
* 店铺收支明细或其他动账汇总。
*/
final class FinanceLedgerIndependentAccounts
{
public const ACCOUNTS = [
'朱俊祥支付宝',
'朱俊祥微信',
];
public static function contains(mixed $accountShort): bool
{
return in_array(trim((string) $accountShort), self::ACCOUNTS, true);
}
public static function names(): array
{
return self::ACCOUNTS;
}
public static function exclude(Builder $query, string $column = 'account_short'): Builder
{
return $query->where(function (Builder $scope) use ($column): void {
$scope->whereNull($column)->orWhereNotIn($column, self::ACCOUNTS);
});
}
}

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
@ -48,6 +49,11 @@ class CompanyAlipayBillImporter
}
$account = $this->account($accountNo, $accountShort);
if (FinanceLedgerIndependentAccounts::contains($account->account_short)) {
throw new InvalidArgumentException(
'“' . $account->account_short . '”是独立月度账单账户,请使用账务组合查询月度导入。',
);
}
$rows = $this->rows($path, $accountNo, $account->account_short, $date);
if ($rows === []) {
throw new InvalidArgumentException("支付宝账单中没有 {$date} 的有效交易,已保留原有动账明细。");

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
@ -86,6 +87,9 @@ class CompanyAlipayStatementImporter
));
}
if (FinanceLedgerIndependentAccounts::contains($account->account_short) && $replaceSources === []) {
$replaceSources = [self::SOURCE];
}
$replaceIds = $this->replacementIds($account, $from, $to, $replaceSources);
$result = [
'dry_run' => $dryRun,

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
@ -185,7 +186,7 @@ class FinanceLedgerAccountTemplate
private function accountTypes(): array
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$base = $connection->table('fin_accounts')
$base = FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all();
$overrides = $connection->table('fin_account_type_overrides')

View File

@ -3,6 +3,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Support\Facades\DB;
/**
@ -30,7 +31,9 @@ class FinanceLedgerCalculator
->groupBy('account_short')
->map(static fn ($rows) => $rows->last());
$accountCompanies = $connection->table('fin_accounts')
$accountCompanies = FinanceLedgerIndependentAccounts::exclude(
$connection->table('fin_accounts'),
)
->select(['account_short', 'company_name'])
->whereNotNull('account_short')
->where('account_short', '<>', '')
@ -225,11 +228,24 @@ class FinanceLedgerCalculator
$this->refreshAccountAmounts();
$companies = $connection->table('fin_accounts')
$companies = FinanceLedgerIndependentAccounts::exclude(
$connection->table('fin_accounts'),
)
->selectRaw('company_name, SUM(current_balance) AS balance')
->groupBy('company_name')
->pluck('balance', 'company_name')
->all();
foreach ($connection->table('fin_accounts')
->whereIn('account_short', FinanceLedgerIndependentAccounts::names())
->whereNotNull('company_name')
->where('company_name', '<>', '')
->distinct()
->pluck('company_name') as $companyName) {
$companyName = (string) $companyName;
if (! array_key_exists($companyName, $companies)) {
$companies[$companyName] = 0;
}
}
$this->updateBalances(
$connection,
@ -259,6 +275,10 @@ class FinanceLedgerCalculator
{
$connection = $this->connection();
if ($accountShort !== null) {
if (FinanceLedgerIndependentAccounts::contains($accountShort)) {
return 0;
}
$status = $connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->value('status');
@ -279,10 +299,12 @@ class FinanceLedgerCalculator
->where('status', '<>', FinanceLedgerAccountStatus::NORMAL)
->pluck('account_short')
->filter()
->reject(static fn (mixed $account): bool => FinanceLedgerIndependentAccounts::contains($account))
->unique()
->values()
->all();
$normalCount = $connection->table('fin_accounts')->update([
$normalQuery = FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'));
$normalCount = $normalQuery->update([
'daily_available' => $connection->raw('current_balance'),
'blocked_amount' => 0,
'updated_at' => now(),

View File

@ -3,6 +3,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Support\Facades\DB;
/**
@ -17,7 +18,8 @@ class FinanceLedgerPayableMappingBulk
{
$connection = $this->connection();
$accountsByCompany = [];
foreach ($connection->table('fin_accounts')->get(['company_name', 'account_short', 'account_type']) as $account) {
foreach (FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))
->get(['company_name', 'account_short', 'account_type']) as $account) {
$company = trim((string) $account->company_name);
$accountShort = trim((string) $account->account_short);
if ($company === '' || $accountShort === '') {

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
@ -192,7 +193,7 @@ class FinanceLedgerShopDirectory
return ['by_account_no' => [], 'by_short' => []];
}
$accounts = $connection->table('fin_accounts')
$accounts = FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))
->get(['account_short', 'account_no', 'bank_name', 'account_type'])
->filter(function (object $account): bool {
$label = (string) ($account->account_type ?? '') . ' ' . (string) ($account->bank_name ?? '');

View File

@ -3,6 +3,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Support\Facades\DB;
/**
@ -22,7 +23,10 @@ class FinanceLedgerShopMappingBulk
->where('account_short', '<>', '/')
->pluck('account_short', 'shop_name');
$masterAccounts = array_fill_keys(
$connection->table('fin_accounts')->distinct()->pluck('account_short')->all(),
FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'))
->distinct()
->pluck('account_short')
->all(),
true,
);
$rows = $connection->table('fin_account_map')

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Support\Facades\DB;
use RuntimeException;
@ -102,6 +103,7 @@ class FinanceLedgerSnapshotImporter
$connection->transaction(function () use ($connection, $rows, $now): void {
$connection->table('fin_transactions')
->where('source', '<>', FinanceLedgerTransactionWriter::MANUAL_SOURCE)
->whereNotIn('account_short', FinanceLedgerIndependentAccounts::names())
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_transactions')->insert($chunk);

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Support\Facades\DB;
use RuntimeException;
@ -101,6 +102,7 @@ class FinanceLedgerTransactionWriter
if ($replaceExisting) {
$connection->table('fin_transactions')
->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE])
->whereNotIn('account_short', FinanceLedgerIndependentAccounts::names())
->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {

View File

@ -8,6 +8,7 @@ use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use App\Utilities\FinanceLedgerAccess;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Carbon\CarbonImmutable;
use Illuminate\Database\Query\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@ -37,9 +38,9 @@ class FinanceLedgerPageData
];
public const TRANSACTION_VIEWS = [
'all' => ['title' => '全部明细', 'account_short' => null],
'zhu-junxiang-alipay' => ['title' => '朱俊祥支付宝', 'account_short' => '朱俊祥支付宝'],
'zhu-junxiang-wechat' => ['title' => '朱俊祥微信', 'account_short' => '朱俊祥微信'],
'all' => ['title' => '全部明细', 'account_short' => null, 'independent' => false],
'zhu-junxiang-alipay' => ['title' => '朱俊祥支付宝', 'account_short' => '朱俊祥支付宝', 'independent' => true],
'zhu-junxiang-wechat' => ['title' => '朱俊祥微信', 'account_short' => '朱俊祥微信', 'independent' => true],
];
private const ACCOUNT_FILTER_EMPTY = '__empty__';
@ -66,7 +67,9 @@ class FinanceLedgerPageData
$availableBreakdown = $this->availableBreakdown($shops);
$flows = $this->flows($flowPeriod);
$transactions = $this->transactions($transactionView['account_short'], $transactionPeriod);
$transactionViewAccountExists = $transactionView['account_short'] === null
$transactionViewIsIndependent = (bool) ($transactionView['independent'] ?? false);
$transactionViewAccountExists = $transactionViewIsIndependent
|| $transactionView['account_short'] === null
|| $this->db()->table('fin_accounts')
->where('account_short', $transactionView['account_short'])
->exists();
@ -93,6 +96,7 @@ class FinanceLedgerPageData
'ledgerTransactionViews' => self::TRANSACTION_VIEWS,
'ledgerTransactionViewAccount' => $transactionView['account_short'],
'ledgerTransactionViewAccountExists' => $transactionViewAccountExists,
'ledgerTransactionViewIsIndependent' => $transactionViewIsIndependent,
'ledgerMapping' => $this->mapping($mappingPeriod),
'ledgerMappingPeriod' => $mappingPeriod,
'ledgerSourceSummary' => $this->sourceSummary($sourcePeriod),
@ -116,7 +120,10 @@ class FinanceLedgerPageData
private function accounts(): array
{
$query = $this->db()->table('fin_accounts as a');
$query = FinanceLedgerIndependentAccounts::exclude(
$this->db()->table('fin_accounts as a'),
'a.account_short',
);
$keyword = trim((string) request('accounts_q', ''));
if ($keyword !== '') {
$query->where(function (Builder $builder) use ($keyword): void {
@ -534,7 +541,8 @@ class FinanceLedgerPageData
*/
private function availableBreakdown(array $shops): array
{
$bankAvailable = round((float) $this->db()->table('fin_accounts')->sum('daily_available'), 2);
$bankAvailableQuery = FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'));
$bankAvailable = round((float) $bankAvailableQuery->sum('daily_available'), 2);
$shopAvailable = round((float) ($shops['overall_summary']['balance'] ?? 0), 2);
return [
@ -547,7 +555,7 @@ class FinanceLedgerPageData
private function flows(array $period): array
{
$query = $this->db()->table('fin_daily_flows');
$query = FinanceLedgerIndependentAccounts::exclude($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, ''));
@ -629,6 +637,7 @@ class FinanceLedgerPageData
if ($forcedAccountShort !== null) {
$query->whereRaw($effectiveAccount . ' = ?', [$forcedAccountShort]);
} else {
$this->excludeIndependentEffectiveAccount($query);
$account = trim((string) request('txns_account', ''));
if ($account !== '') {
$query->whereRaw($effectiveAccount . ' LIKE ?', ['%' . $account . '%']);
@ -712,12 +721,18 @@ class FinanceLedgerPageData
private function transactionSummaryMetrics(Builder $query, ?string $accountShort, array $period): array
{
$isIndependent = FinanceLedgerIndependentAccounts::contains($accountShort);
return [
'count' => (clone $query)->count(),
'previous_closing' => $this->transactionPreviousMonthClosing($accountShort, $period),
'previous_closing' => $isIndependent
? $this->independentTransactionOpening((string) $accountShort, $period)
: $this->transactionPreviousMonthClosing($accountShort, $period),
'income' => round((float) (clone $query)->sum('t.income'), 2),
'expense' => round((float) (clone $query)->sum('t.expense'), 2),
'current_balance' => $this->transactionCurrentBalance($accountShort),
'current_balance' => $isIndependent
? $this->independentTransactionCurrentBalance((string) $accountShort)
: $this->transactionCurrentBalance($accountShort),
];
}
@ -727,6 +742,7 @@ class FinanceLedgerPageData
if ($accountShort !== null) {
$query->where('account_short', $accountShort);
} else {
FinanceLedgerIndependentAccounts::exclude($query);
$account = trim((string) request('txns_account', ''));
if ($account !== '') {
$query->where('account_short', 'like', '%' . $account . '%');
@ -790,20 +806,83 @@ class FinanceLedgerPageData
return;
}
FinanceLedgerIndependentAccounts::exclude($query, 'f.account_short');
$account = trim((string) request('txns_account', ''));
if ($account !== '') {
$query->where('f.account_short', 'like', '%' . $account . '%');
}
}
public function globalTransactionCount(): int
{
$query = $this->transactionScopeQuery();
$this->excludeIndependentEffectiveAccount($query);
return (clone $query)->count();
}
private function independentTransactionOpening(string $accountShort, array $period): ?float
{
$historyQuery = $this->transactionScopeQuery();
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
$historyQuery->whereRaw($effectiveAccount . ' = ?', [$accountShort])
->whereNotNull('t.balance');
$periodQuery = clone $historyQuery;
$this->applyDateRange($periodQuery, 't.txn_date', $period);
$first = $periodQuery
->orderBy('t.txn_date')
->orderBy('t.id')
->first(['t.txn_date', 't.income', 't.expense', 't.balance']);
if ($first !== null) {
return round(
(float) $first->balance - (float) $first->income + (float) $first->expense,
2,
);
}
$from = $period['from'] ?? null;
if ($from === null) {
return null;
}
$prior = (clone $historyQuery)
->where('t.txn_date', '<', $from)
->orderByDesc('t.txn_date')
->orderByDesc('t.id')
->value('t.balance');
return $prior === null ? null : round((float) $prior, 2);
}
private function independentTransactionCurrentBalance(string $accountShort): ?float
{
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
$balance = $this->transactionScopeQuery()
->whereRaw($effectiveAccount . ' = ?', [$accountShort])
->whereNotNull('t.balance')
->orderByDesc('t.txn_date')
->orderByDesc('t.id')
->value('t.balance');
return $balance === null ? null : round((float) $balance, 2);
}
private function excludeIndependentEffectiveAccount(Builder $query): void
{
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
$names = FinanceLedgerIndependentAccounts::names();
$query->whereRaw(
$effectiveAccount . ' NOT IN (' . implode(', ', array_fill(0, count($names), '?')) . ')',
$names,
);
}
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');
})
return $this->transactionScopeQuery()
->select([
't.id',
't.txn_date',
@ -826,15 +905,21 @@ class FinanceLedgerPageData
->selectRaw($effectiveAccount . ' AS effective_account_short');
}
private function transactionScopeQuery(): Builder
{
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');
});
}
private function transactionAccountHeaderOptions(): array
{
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
$query = $this->transactionScopeQuery();
$this->excludeIndependentEffectiveAccount($query);
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');
})
return $query
->selectRaw($effectiveAccount . ' AS filter_value')
->distinct()
->orderBy('filter_value')
@ -848,11 +933,14 @@ class FinanceLedgerPageData
private function transactionDateHeaderOptions(): array
{
return $this->db()->table('fin_transactions')
->select('txn_date')
$query = $this->transactionScopeQuery();
$this->excludeIndependentEffectiveAccount($query);
return $query
->select('t.txn_date')
->distinct()
->orderByDesc('txn_date')
->pluck('txn_date')
->orderByDesc('t.txn_date')
->pluck('t.txn_date')
->map(static fn (mixed $value): string => (string) $value)
->filter()
->unique()
@ -953,6 +1041,12 @@ class FinanceLedgerPageData
->selectRaw("SUM({$t}.income) AS income_sum")
->selectRaw("SUM({$t}.expense) AS expense_sum")
->groupBy('t.source', 't.source_value');
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
$names = FinanceLedgerIndependentAccounts::names();
$query->whereRaw(
$effectiveAccount . ' NOT IN (' . implode(', ', array_fill(0, count($names), '?')) . ')',
$names,
);
$this->applyDateRange($query, 't.txn_date', $period);
return $query;
@ -960,12 +1054,15 @@ class FinanceLedgerPageData
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')
$t = $this->queryAlias('t');
$query = $this->transactionScopeQuery();
$this->excludeIndependentEffectiveAccount($query);
$query
->selectRaw("{$t}.source, {$t}.process_type, COUNT(*) AS rows_count, SUM({$t}.income) AS income, SUM({$t}.expense) AS expense, MIN({$t}.txn_date) AS first_date, MAX({$t}.txn_date) AS last_date")
->groupBy('t.source', 't.process_type')
->orderBy('t.source')
->orderByDesc('rows_count');
$this->applyDateRange($query, 'txn_date', $period);
$this->applyDateRange($query, 't.txn_date', $period);
$rows = $query
->get()
@ -982,24 +1079,28 @@ class FinanceLedgerPageData
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');
$transactionQuery = $this->transactionScopeQuery();
$this->excludeIndependentEffectiveAccount($transactionQuery);
$this->applyDateRange($transactionQuery, 't.txn_date', $transactionPeriod);
$flowQuery = FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_daily_flows'));
$this->applyDateRange($flowQuery, 'flow_date', $flowPeriod);
$accountQuery = FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'));
$openingQuery = FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_account_opening'));
$mappingQuery = FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_account_map'));
return [
'accounts' => $this->db()->table('fin_accounts')->count(),
'accounts' => (clone $accountQuery)->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),
'opening_rows' => (clone $openingQuery)->count(),
'opening_sum' => round((float) (clone $openingQuery)->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'),
'mapping_total' => (clone $mappingQuery)->count(),
'mapping_done' => (clone $mappingQuery)->whereNotNull('account_short')->where('account_short', '<>', '')->count(),
'last_synced_at' => (clone $transactionQuery)->max('t.synced_at'),
];
}
@ -1019,19 +1120,27 @@ class FinanceLedgerPageData
private function accountOptions(): array
{
return $this->db()->table('fin_accounts')
return FinanceLedgerIndependentAccounts::exclude($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')
$view = $this->transactionView();
$query = $this->db()->table('fin_accounts')
->select(['account_short', 'company_name'])
->whereNotNull('account_short')
->where('account_short', '<>', '')
->orderBy('account_short')
->orderBy('company_name')
->orderBy('id')
->orderBy('id');
if (FinanceLedgerIndependentAccounts::contains($view['account_short'])) {
$query->where('account_short', $view['account_short']);
} else {
FinanceLedgerIndependentAccounts::exclude($query);
}
return $query
->get()
->groupBy('account_short')
->map(static function ($rows, string $accountShort): array {
@ -1052,16 +1161,17 @@ class FinanceLedgerPageData
private function accountFilterOptions(): array
{
$baseTypes = $this->db()->table('fin_accounts')
$baseTypes = FinanceLedgerIndependentAccounts::exclude($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')
->whereNotIn('account_short', FinanceLedgerIndependentAccounts::names())
->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')
'regions' => FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'))
->whereNotNull('region')->where('region', '<>', '')
->distinct()->orderBy('region')->pluck('region')->all(),
'types' => $types,
@ -1070,7 +1180,7 @@ class FinanceLedgerPageData
private function accountPlanOptions(): array
{
return $this->db()->table('fin_accounts')
return FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'))
->whereNotNull('company_plan')
->where('company_plan', '<>', '')
->distinct()
@ -1089,7 +1199,7 @@ class FinanceLedgerPageData
->whereNotNull('name')
->pluck('name')
->merge(
$this->db()->table('fin_accounts')
FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'))
->whereNotNull('company_name')
->distinct()
->pluck('company_name'),

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\View;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
@ -338,7 +339,7 @@ class ShopFlowQuery
->pluck('company_name'),
)
->merge(
$this->db()->table('fin_accounts')
FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'))
->whereNotNull('company_name')
->where('company_name', '<>', '')
->distinct()
@ -386,7 +387,7 @@ class ShopFlowQuery
private function accountRows(): array
{
return $this->db()->table('fin_accounts')
return FinanceLedgerIndependentAccounts::exclude($this->db()->table('fin_accounts'))
->orderBy('company_name')
->orderBy('account_short')
->orderBy('id')
@ -469,7 +470,7 @@ class ShopFlowQuery
private function transactionQuery(string $accountShort, string $start, string $end)
{
// 钉钉付款的原始 account_short 常为空,按账户映射解析;无映射时保留原值兜底。
return $this->db()->table('fin_transactions as t')
$query = $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');
@ -492,6 +493,15 @@ class ShopFlowQuery
$expense->where('t.source', 'dingtalk')->where('t.expense', '>', 0);
});
});
$prefix = $this->db()->getTablePrefix();
$effectiveAccount = "COALESCE(NULLIF({$prefix}m.account_short, ''), NULLIF({$prefix}t.account_short, ''), '未匹配')";
$names = FinanceLedgerIndependentAccounts::names();
return $query->whereRaw(
$effectiveAccount . ' NOT IN (' . implode(', ', array_fill(0, count($names), '?')) . ')',
$names,
);
}
private function transactionColumns(): array

View File

@ -66,6 +66,7 @@
$transactionViews = $ledgerTransactionViews ?? [];
$transactionViewAccount = $ledgerTransactionViewAccount ?? null;
$transactionViewAccountExists = (bool) ($ledgerTransactionViewAccountExists ?? true);
$transactionViewIsIndependent = (bool) ($ledgerTransactionViewIsIndependent ?? false);
$currentMonthStart = now()->startOfMonth()->toDateString();
$currentMonthEnd = now()->endOfMonth()->toDateString();
$flowPeriod = $ledgerFlowPeriod ?? ['from' => $currentMonthStart, 'to' => $currentMonthEnd];
@ -844,7 +845,7 @@
<button class="ledger-company-form__close" type="button" aria-label="关闭手工明细编辑" data-ledger-manual-edit-close><span class="material-icons-outlined" aria-hidden="true">close</span></button>
</header>
<div class="ledger-company-form__body">
<div class="ledger-company-field"><label for="ledger-manual-edit-date">日期 <span aria-hidden="true">*</span></label><input id="ledger-manual-edit-date" name="txn_date" type="date" min="{{ $transactionPeriod['from'] ?? $ledgerOpeningDate }}" max="{{ $transactionPeriod['to'] ?? '' }}" required data-ledger-manual-edit-date></div>
<div class="ledger-company-field"><label for="ledger-manual-edit-date">日期 <span aria-hidden="true">*</span></label><input id="ledger-manual-edit-date" name="txn_date" type="date" @if (!$transactionViewIsIndependent) min="{{ $transactionPeriod['from'] ?? $ledgerOpeningDate }}" max="{{ $transactionPeriod['to'] ?? '' }}" @endif required data-ledger-manual-edit-date></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-manual-edit-account">账户简称 <span aria-hidden="true">*</span></label><input id="ledger-manual-edit-account" name="account_short" type="text" list="ledger-manual-account-options" maxlength="100" placeholder="输入账户简称" autocomplete="off" required data-ledger-manual-edit-account></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-manual-edit-counterparty">对方信息</label><input id="ledger-manual-edit-counterparty" name="counterparty_info" type="text" maxlength="255" placeholder="对方账户 / 名称" data-ledger-manual-edit-counterparty></div>
<div class="ledger-company-field"><label for="ledger-manual-edit-balance">余额(元)</label><input id="ledger-manual-edit-balance" name="balance" type="number" step="0.01" placeholder="交易后余额" data-ledger-manual-edit-balance></div>
@ -1091,15 +1092,17 @@
</section>
{{-- 动账明细 --}}
<section class="ledger-panel" id="ledger-panel-transactions" role="tabpanel" data-ledger-panel="transactions" data-transaction-view="{{ $transactionView }}" data-transaction-account-url-template="{{ route('business.financial-ledger.transactions.account.update', ['id' => '__TRANSACTION__']) }}" data-transaction-column-storage-key="caiwu.financial-ledger.transactions.widths.v1.{{ auth()->id() }}.{{ company_id() }}" @if ($activeTab !== 'transactions') hidden @endif>
<section class="ledger-panel" id="ledger-panel-transactions" role="tabpanel" data-ledger-panel="transactions" data-transaction-view="{{ $transactionView }}" data-transaction-view-independent="{{ $transactionViewIsIndependent ? '1' : '0' }}" data-transaction-account-url-template="{{ route('business.financial-ledger.transactions.account.update', ['id' => '__TRANSACTION__']) }}" data-transaction-column-storage-key="caiwu.financial-ledger.transactions.widths.v1.{{ auth()->id() }}.{{ company_id() }}" @if ($activeTab !== 'transactions') hidden @endif>
<nav class="ledger-subtabbar" role="tablist" aria-label="动账明细分页">
@foreach ($transactionViews as $viewKey => $view)
<button type="button" class="ledger-subtab {{ $transactionView === $viewKey ? 'is-active' : '' }}" role="tab" aria-selected="{{ $transactionView === $viewKey ? 'true' : 'false' }}" data-ledger-transaction-view="{{ $viewKey }}" data-ledger-transaction-view-account="{{ $view['account_short'] ?? '' }}">{{ $view['title'] }}</button>
<button type="button" class="ledger-subtab {{ $transactionView === $viewKey ? 'is-active' : '' }}" role="tab" aria-selected="{{ $transactionView === $viewKey ? 'true' : 'false' }}" data-ledger-transaction-view="{{ $viewKey }}" data-ledger-transaction-view-account="{{ $view['account_short'] ?? '' }}" data-ledger-transaction-view-independent="{{ !empty($view['independent']) ? '1' : '0' }}">{{ $view['title'] }}</button>
@endforeach
</nav>
<div class="ledger-transaction-view-note {{ $transactionViewAccount !== null && !$transactionViewAccountExists ? 'is-warning' : '' }}" data-ledger-transaction-view-note role="status" aria-live="polite">
@if ($transactionViewAccount === null)
全部动账明细统一展示;选择账户专属页后只显示该账户的真实收支明细。
@elseif ($transactionViewIsIndependent)
当前为“{{ $transactionViewAccount }}”独立账单页;月度导入和手工明细只在本页显示与汇总,不进入账户总表、每日资金流水或其他页面。
@elseif (!$transactionViewAccountExists)
账户总表中尚未建立“{{ $transactionViewAccount }}”,当前仅展示已归集到该账户简称的历史明细。先在账户总表补齐账户和期初后,才会进入每日资金流水及当前余额重算。
@else
@ -1165,7 +1168,7 @@
<input type="hidden" name="idempotency_key" value="{{ (string) \Illuminate\Support\Str::uuid() }}" data-ledger-manual-token>
</form>
<div class="ledger-manual-date">
<input id="ledger-manual-date" class="ledger-manual-input" form="ledger-manual-transaction-form" type="date" name="txn_date" value="{{ $manualTransactionDate }}" min="{{ $transactionPeriod['from'] ?? $ledgerOpeningDate }}" max="{{ $transactionPeriod['to'] ?? '' }}" required data-ledger-manual-date>
<input id="ledger-manual-date" class="ledger-manual-input" form="ledger-manual-transaction-form" type="date" name="txn_date" value="{{ $manualTransactionDate }}" @if (!$transactionViewIsIndependent) min="{{ $transactionPeriod['from'] ?? $ledgerOpeningDate }}" max="{{ $transactionPeriod['to'] ?? '' }}" @endif required data-ledger-manual-date>
<button class="ledger-manual-date__button" type="button" aria-label="选择手工明细日期" title="选择日期" data-ledger-manual-date-picker>
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
@ -1409,6 +1412,11 @@
});
if (!transactionPanel) return;
transactionPanel.dataset.transactionView = viewKey;
const activeViewTab = transactionViewTabs.find(
(tab) => tab.dataset.ledgerTransactionView === viewKey,
);
transactionPanel.dataset.transactionViewIndependent =
activeViewTab?.dataset.ledgerTransactionViewIndependent || '0';
const viewInput = transactionPanel.querySelector('input[name="txns_view"]');
if (viewInput) viewInput.value = viewKey;
const accountFilter = transactionPanel.querySelector('[data-ledger-account-filter-input]');
@ -1471,10 +1479,12 @@
const nextPagination = nextPanel?.querySelector('[data-ledger-transaction-pagination]');
const nextNote = nextPanel?.querySelector('[data-ledger-transaction-view-note]');
const nextManualTrigger = nextPanel?.querySelector('[data-ledger-manual-trigger]');
const nextAccountOptions = nextPanel?.querySelector('#ledger-manual-account-options');
const currentBody = transactionPanel.querySelector('[data-ledger-transaction-tbody]');
const currentPagination = transactionPanel.querySelector('[data-ledger-transaction-pagination]');
const currentNote = transactionPanel.querySelector('[data-ledger-transaction-view-note]');
const currentManualTrigger = transactionPanel.querySelector('[data-ledger-manual-trigger]');
const currentAccountOptions = transactionPanel.querySelector('#ledger-manual-account-options');
if (!nextBody || !nextPagination || !nextNote || !currentBody || !currentPagination || !currentNote) {
throw new Error('明细页内容解析失败');
}
@ -1488,7 +1498,10 @@
? '[data-ledger-transaction-summary-expense]'
: '[data-ledger-transaction-summary-current-balance]';
const currentValue = transactionPanel.querySelector(selector);
if (currentValue) currentValue.textContent = nextValue.textContent;
if (currentValue) {
currentValue.textContent = nextValue.textContent;
currentValue.title = nextValue.title;
}
});
transactionPanel.querySelectorAll('[data-ledger-transaction-editor] .ledger-transaction-row.is-new')
.forEach((row) => row.remove());
@ -1498,6 +1511,14 @@
if (currentManualTrigger && nextManualTrigger) {
currentManualTrigger.disabled = nextManualTrigger.disabled;
}
if (currentAccountOptions && nextAccountOptions) {
const importedAccountOptions = document.importNode(nextAccountOptions, true);
currentAccountOptions.replaceWith(importedAccountOptions);
ledgerAccountShortValues.clear();
Array.from(importedAccountOptions.options).forEach((option) => {
ledgerAccountShortValues.add(option.value);
});
}
applyTransactionViewControls(viewKey, accountShort);
bindTransactionRowsAfterLoad();
bindTransactionPaginationAfterLoad();
@ -1960,6 +1981,8 @@
Array.from(ledgerAccountOptions?.options || []).map((option) => option.value)
);
const ledgerCsrfToken = manualTransactionForm?.querySelector('input[name="_token"]')?.value || '';
const transactionViewIsIndependent = () =>
transactionPanel?.dataset.transactionViewIndependent === '1';
const createTransactionAccountInput = (transactionId, accountShort, accountGroup) => {
const input = document.createElement('input');
@ -2257,7 +2280,9 @@
const balance = payload.daily_flow
? ',当日期末余额 ' + moneyFormatter.format(payload.daily_flow.closing_balance)
: '';
notify('手工明细已保存并计入每日资金流水' + balance);
notify(transactionViewIsIndependent()
? '手工明细已保存到独立账单'
: '手工明细已保存并计入每日资金流水' + balance);
} catch (error) {
notify(error.message || '手工明细保存失败', true);
} finally {
@ -2334,7 +2359,9 @@
const row = button.closest('[data-ledger-manual-row]');
if (!row) return;
const label = [row.dataset.txnDate, row.dataset.accountShort, row.dataset.detail].filter(Boolean).join(' · ');
if (!window.confirm('确认删除这条手工明细?\n' + label + '\n删除后将重新计算资金流水。')) return;
if (!window.confirm('确认删除这条手工明细?\n' + label + '\n' + (transactionViewIsIndependent()
? '删除后将重新计算本页月度汇总。'
: '删除后将重新计算资金流水。'))) return;
button.disabled = true;
try {

View File

@ -229,6 +229,111 @@ class FinanceLedgerHeaderControlsTest extends FeatureTestCase
$this->assertStringNotContainsString('data-ledger-transaction-summary-net', $html);
}
public function test_independent_statement_summary_uses_only_its_own_monthly_balances(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥支付宝',
'account_type' => '支付宝',
'current_balance' => 999999,
]);
$connection->table('fin_transactions')->insert([
[
'txn_date' => '2026-10-01',
'account_short' => '朱俊祥支付宝',
'detail' => '独立账单首笔',
'income' => 10,
'expense' => 0,
'balance' => 110,
'approval_no' => 'INDEPENDENT-INCOME',
'source' => 'alipay-company',
'source_value' => 'ALI-INDEPENDENT',
'row_key' => 'independent-summary-income',
],
[
'txn_date' => '2026-10-02',
'account_short' => '朱俊祥支付宝',
'detail' => '独立账单末笔',
'income' => 0,
'expense' => 20,
'balance' => 90,
'approval_no' => 'INDEPENDENT-EXPENSE',
'source' => 'alipay-company',
'source_value' => 'ALI-INDEPENDENT',
'row_key' => 'independent-summary-expense',
],
]);
$html = $this->loginAs()->get($this->pageUrl([
'txns_view' => 'zhu-junxiang-alipay',
'txns_from' => '2026-10-01',
'txns_to' => '2026-10-31',
]))->assertOk()->getContent();
$this->assertSame(1, preg_match('/(<div class="ledger-summary" data-ledger-transaction-summary>.*?)(?=<div class="ledger-note">)/s', $html, $matches));
$summaryHtml = $matches[1];
$this->assertStringContainsString('>上月期末余额(万元)</span><b title="100.00 元" data-ledger-transaction-summary-previous-closing>0.01</b>', $summaryHtml);
$this->assertStringContainsString('>当月收入(万元)</span><b title="10.00 元" data-ledger-transaction-summary-income>0.00</b>', $summaryHtml);
$this->assertStringContainsString('>当月支出(万元)</span><b title="20.00 元" data-ledger-transaction-summary-expense>0.00</b>', $summaryHtml);
$this->assertStringContainsString('>当前余额(万元)</span><b title="90.00 元" data-ledger-transaction-summary-current-balance>0.01</b>', $summaryHtml);
$this->assertStringContainsString('独立账单页', $html);
$this->assertSame(1, preg_match('/<input id="ledger-manual-date"[^>]*>/', $html, $dateInput));
$this->assertStringNotContainsString('min=', $dateInput[0]);
$allHtml = $this->loginAs()->get($this->pageUrl([
'txns_from' => '2026-10-01',
'txns_to' => '2026-10-31',
]))->assertOk()->getContent();
$this->assertStringNotContainsString('独立账单首笔', $allHtml);
$this->assertStringNotContainsString('独立账单末笔', $allHtml);
$this->assertSame(1, preg_match('/<input id="ledger-manual-date"[^>]*>/', $allHtml, $allDateInput));
$this->assertStringContainsString('min="2026-10-01"', $allDateInput[0]);
$nextMonthHtml = $this->loginAs()->get($this->pageUrl([
'txns_view' => 'zhu-junxiang-alipay',
'txns_from' => '2026-11-01',
'txns_to' => '2026-11-30',
]))->assertOk()->getContent();
$this->assertSame(1, preg_match('/(<div class="ledger-summary" data-ledger-transaction-summary>.*?)(?=<div class="ledger-note">)/s', $nextMonthHtml, $nextMatches));
$this->assertStringContainsString('title="90.00 元" data-ledger-transaction-summary-previous-closing>0.01</b>', $nextMatches[1]);
$this->assertStringContainsString('title="90.00 元" data-ledger-transaction-summary-current-balance>0.01</b>', $nextMatches[1]);
$this->assertStringContainsString('data-ledger-transaction-summary-income>0.00</b>', $nextMatches[1]);
$this->assertStringContainsString('data-ledger-transaction-summary-expense>0.00</b>', $nextMatches[1]);
}
public function test_independent_statement_accounts_are_hidden_from_shared_ledger_views(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥支付宝',
'account_type' => '支付宝',
'current_balance' => 999999,
'daily_available' => 999999,
]);
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-18',
'account_short' => '朱俊祥支付宝',
'detail' => '共享明细不应出现',
'income' => 10,
'expense' => 0,
'balance' => 100,
'approval_no' => 'INDEPENDENT-HIDDEN',
'source' => 'alipay-company',
'source_value' => 'ALI-INDEPENDENT',
'row_key' => 'independent-hidden-row',
]);
$html = $this->loginAs()->get($this->pageUrl() . '?tab=accounts')->assertOk()->getContent();
$this->assertSame(1, preg_match('/<table class="ledger-table ledger-accounts-table">.*?<\/table>/s', $html, $matches));
$this->assertStringNotContainsString('朱俊祥支付宝', $matches[0]);
$this->assertStringNotContainsString('共享明细不应出现', $html);
$this->assertStringNotContainsString('999999.00', $html);
}
public function test_shop_ledger_header_labels_and_column_order(): void
{
$html = $this->loginAs()->get($this->pageUrl())->assertOk()->getContent();

View File

@ -7,6 +7,7 @@ use App\Models\Auth\Role;
use App\Models\Auth\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\FinanceLedgerSnapshotImporter;
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
use Tests\Feature\FeatureTestCase;
@ -194,7 +195,8 @@ class FinanceLedgerManualTransactionTest extends FeatureTestCase
->assertOk()
->assertSee('data-ledger-transaction-view="zhu-junxiang-alipay"', false)
->assertSee('data-ledger-transaction-view="zhu-junxiang-wechat"', false)
->assertSee('账户总表当前余额和每日资金流水')
->assertSee('独立账单页')
->assertSee('不进入账户总表、每日资金流水或其他页面')
->assertSee('支付宝专属页收入')
->assertDontSee('微信专属页支出')
->assertDontSee('不应出现在专属页');
@ -204,9 +206,15 @@ class FinanceLedgerManualTransactionTest extends FeatureTestCase
->assertSee('微信专属页支出')
->assertDontSee('支付宝专属页收入')
->assertDontSee('不应出现在专属页');
$this->loginAs()->get($this->pageUrl())
->assertOk()
->assertDontSee('支付宝专属页收入')
->assertDontSee('微信专属页支出')
->assertSee('不应出现在专属页');
}
public function test_account_specific_manual_transaction_updates_daily_flow_and_account_balance(): void
public function test_account_specific_manual_transaction_stays_inside_the_independent_statement(): void
{
$this->addZhuJunxiangAccounts();
@ -216,29 +224,46 @@ class FinanceLedgerManualTransactionTest extends FeatureTestCase
'idempotency_key' => '553ca003-8a20-4762-b1c9-1442b7f7d0b1',
'account_short' => '朱俊祥微信',
'detail' => '朱俊祥微信专属页手工支出',
'txn_date' => '2026-10-01',
'income' => 0,
'expense' => 20,
'balance' => 30,
]),
)
->assertCreated()
->assertJsonPath('summary.count', 1)
->assertJsonPath('daily_flow.opening_balance', 50)
->assertJsonPath('daily_flow.outflow', 20)
->assertJsonPath('daily_flow.closing_balance', 30)
->assertJsonPath('summary.expense', 20)
->assertJsonPath('summary.current_balance', 30)
->assertJsonPath('daily_flow', null)
->assertJsonPath('account.current_balance', 30);
$this->assertDatabaseHas('fin_daily_flows', [
'flow_date' => '2026-10-18',
'company_name' => '朱俊祥',
$connection = DB::connection('business_mirror');
$this->assertDatabaseMissing('fin_daily_flows', [
'account_short' => '朱俊祥微信',
'opening_balance' => 50,
'outflow' => 20,
'closing_balance' => 30,
], 'business_mirror');
$this->assertDatabaseHas('fin_accounts', [
'account_short' => '朱俊祥微信',
'current_balance' => 30,
'current_balance' => 50,
], 'business_mirror');
$transaction = $connection->table('fin_transactions')
->where('account_short', '朱俊祥微信')
->whereDate('txn_date', '2026-10-01')
->where('detail', '朱俊祥微信专属页手工支出')
->first();
$this->assertNotNull($transaction);
$this->loginAs()->postJson($this->manualUpdateUrl((int) $transaction->id), [
'txn_date' => '2026-09-30',
'account_short' => '朱俊祥微信',
'detail' => '独立账单历史日期',
'income' => 0,
'expense' => 20,
'balance' => 25,
'approval_no' => 'INDEPENDENT-EDIT-1',
])
->assertOk()
->assertJsonPath('transaction.txn_date', '2026-09-30')
->assertJsonPath('message', '手工明细已保存到独立账单。');
}
public function test_account_specific_page_explains_when_the_account_master_is_missing(): void
@ -257,8 +282,8 @@ class FinanceLedgerManualTransactionTest extends FeatureTestCase
$this->loginAs()->get($this->pageUrl() . '&txns_view=zhu-junxiang-wechat')
->assertOk()
->assertSee('账户总表中尚未建立“朱俊祥微信”')
->assertSee('才会进入每日资金流水及当前余额重算')
->assertSee('当前为“朱俊祥微信”独立账单页')
->assertDontSee('账户总表中尚未建立“朱俊祥微信”')
->assertSee('缺少账户总表时的微信明细');
}
@ -367,6 +392,19 @@ class FinanceLedgerManualTransactionTest extends FeatureTestCase
->assertJsonPath('created', false);
$connection = DB::connection('business_mirror');
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-18',
'account_short' => '朱俊祥支付宝',
'detail' => '独立月度账单明细',
'income' => 12,
'expense' => 0,
'balance' => 112,
'approval_no' => 'INDEPENDENT-1',
'source' => 'alipay-company',
'source_value' => 'ALI-INDEPENDENT',
'process_type' => '公司支付宝账务流水',
'row_key' => 'independent-statement-1',
]);
$this->assertSame(1, $connection->table('fin_transactions')
->where('source', FinanceLedgerTransactionWriter::MANUAL_SOURCE)
->count());
@ -393,6 +431,56 @@ class FinanceLedgerManualTransactionTest extends FeatureTestCase
'detail' => '上游同步明细',
'source' => 'dingtalk',
], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'account_short' => '朱俊祥支付宝',
'detail' => '独立月度账单明细',
], 'business_mirror');
}
public function test_independent_statement_rows_survive_snapshot_replacement(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-18',
'account_short' => '朱俊祥微信',
'detail' => '独立微信月度账单',
'income' => 20,
'expense' => 0,
'balance' => 120,
'approval_no' => 'INDEPENDENT-WECHAT-1',
'source' => 'alipay-company',
'source_value' => 'WECHAT-INDEPENDENT',
'process_type' => '公司支付宝账务流水',
'row_key' => 'independent-wechat-statement-1',
]);
$path = tempnam(sys_get_temp_dir(), 'ledger-snapshot-') . '.json';
file_put_contents($path, json_encode([
[
'process' => '店铺提现',
'date' => '2026-10-19',
'process_no' => 'SNAPSHOT-1',
'account_short' => '手工账户甲',
'acct_name' => '手工账户甲',
'income' => 15,
'expense' => 0,
'detail' => '快照同步明细',
],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
try {
app(FinanceLedgerSnapshotImporter::class)->import($path, false);
} finally {
@unlink($path);
}
$this->assertDatabaseHas('fin_transactions', [
'account_short' => '朱俊祥微信',
'detail' => '独立微信月度账单',
], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'detail' => '快照同步明细',
'source' => 'shop',
], 'business_mirror');
}
public function test_manual_submit_returns_summary_for_current_transaction_filters(): void

View File

@ -136,6 +136,37 @@ class ShopFlowPageTest extends FeatureTestCase
$this->assertStringNotContainsString('付款审批', $content);
}
public function test_independent_monthly_statement_accounts_do_not_appear_in_shop_flows(): void
{
DB::table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥支付宝',
'current_balance' => 900,
]);
DB::table('fin_transactions')->insert([
'txn_date' => '2026-11-01',
'account_short' => '朱俊祥支付宝',
'detail' => '独立账单不应出现在店铺收支',
'income' => 30,
'expense' => 0,
'approval_no' => 'INDEPENDENT-SHOP-FLOW',
'source' => 'alipay-company',
'source_value' => 'ALI-INDEPENDENT',
'process_type' => '公司支付宝账务流水',
'row_key' => 'independent-shop-flow',
]);
$data = app(ShopFlowQuery::class)->pageData(new Request([
'account' => '朱俊祥支付宝',
'start' => '2026-11-01',
'end' => '2026-11-02',
]));
$this->assertSame('', $data['shopFlowSelectedAccountShort']);
$this->assertCount(0, $data['shopFlowRows']->items());
$this->assertNotContains('朱俊祥支付宝', collect($data['shopFlowAccounts'])->pluck('account_short')->all());
}
private function createFinanceTables(): void
{
Schema::connection('sqlite')->create('fin_companies', function ($table): void {

View File

@ -133,6 +133,29 @@ class ImportCompanyAlipayBillsTest extends FeatureTestCase
$this->assertDatabaseHas('fin_transactions', ['detail' => '原有记录'], 'business_mirror');
}
public function test_independent_monthly_account_rejects_daily_alipay_import(): void
{
DB::connection('business_mirror')->table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥支付宝',
'account_no' => '18800000003',
'account_type' => '支付宝',
]);
$file = $this->csvFile([
['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号'],
['2026-10-29 10:00:00', '甲公司', '月度账单账户', '收入', '10.00', '交易成功', 'ALI-INDEPENDENT'],
]);
$this->withExceptionHandling()->artisan('finance-ledger:import-company-alipay', [
'path' => $file,
'--date' => '2026-10-29',
'--account' => '18800000003',
'--account-short' => '朱俊祥支付宝',
])->assertExitCode(1);
$this->assertDatabaseCount('fin_transactions', 0, 'business_mirror');
}
/** @param list<list<string>> $rows */
private function csvFile(array $rows): string
{

View File

@ -132,6 +132,53 @@ class ImportCompanyAlipayStatementTest extends FeatureTestCase
->value('current_balance'));
}
public function test_independent_account_statement_replaces_only_its_month_and_skips_shared_flows(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_accounts')->insert([
'company_name' => '朱俊祥',
'account_short' => '朱俊祥支付宝',
'account_no' => '18800000009',
'account_type' => '支付宝',
'current_balance' => 50,
]);
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-02',
'account_short' => '朱俊祥支付宝',
'detail' => '待替换月度账单',
'income' => 1,
'expense' => 0,
'balance' => 101,
'source' => 'alipay-company',
'source_value' => '18800000009',
'row_key' => 'old-independent-statement',
]);
$file = $this->statementFile([
['2026-10-03 09:00:00', 'ALI-INDEPENDENT-1', '转账', '10.00', '', '110.00', '138******09', '*甲', '独立账单'],
]);
$this->artisan('finance-ledger:import-alipay-statement', [
'path' => $file,
'--account' => '18800000009',
'--account-short' => '朱俊祥支付宝',
'--from' => '2026-10-01',
'--to' => '2026-10-31',
'--opening-balance' => '100.00',
])->assertExitCode(0);
$this->assertDatabaseMissing('fin_transactions', ['row_key' => 'old-independent-statement'], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'approval_no' => 'ALI-INDEPENDENT-1',
'account_short' => '朱俊祥支付宝',
'balance' => 110,
'source' => 'alipay-company',
], 'business_mirror');
$this->assertDatabaseMissing('fin_daily_flows', ['account_short' => '朱俊祥支付宝'], 'business_mirror');
$this->assertSame(50.0, (float) $connection->table('fin_accounts')
->where('account_short', '朱俊祥支付宝')
->value('current_balance'));
}
/** @param list<list<string>> $rows */
private function statementFile(array $rows): string
{

View File

@ -227,4 +227,50 @@ class FinanceLedgerCalculatorTest extends TestCase
0.001,
);
}
public function test_independent_monthly_statement_accounts_are_excluded_from_global_flows_and_balances(): void
{
$db = DB::connection('business_mirror');
$db->table('fin_accounts')->insert([
[
'company_name' => '独立公司',
'account_short' => '朱俊祥支付宝',
'current_balance' => 900,
],
[
'company_name' => '独立公司',
'account_short' => '朱俊祥微信',
'current_balance' => 800,
],
]);
$db->table('fin_account_opening')->insert([
['account_short' => '朱俊祥支付宝', 'opening_date' => '2026-10-01', 'opening_balance' => 900],
['account_short' => '朱俊祥微信', 'opening_date' => '2026-10-01', 'opening_balance' => 800],
]);
$db->table('fin_transactions')->insert([
['txn_date' => '2026-10-18', 'account_short' => '朱俊祥支付宝', 'source' => 'alipay-company', 'source_value' => 'ALI', 'income' => 100, 'expense' => 0],
['txn_date' => '2026-10-18', 'account_short' => '朱俊祥微信', 'source' => 'manual', 'source_value' => null, 'income' => 0, 'expense' => 20],
]);
$db->table('fin_shops')->insert([
'company_name' => '独立公司',
'shop_balance' => 999,
]);
$result = app(FinanceLedgerCalculator::class)->recalculate();
$this->assertSame(2, $result['accounts']);
$this->assertSame(3, $result['flows']);
$this->assertSame(0, $db->table('fin_daily_flows')
->whereIn('account_short', ['朱俊祥支付宝', '朱俊祥微信'])
->count());
$this->assertEqualsWithDelta(900.00, (float) $db->table('fin_accounts')
->where('account_short', '朱俊祥支付宝')
->value('current_balance'), 0.001);
$this->assertEqualsWithDelta(800.00, (float) $db->table('fin_accounts')
->where('account_short', '朱俊祥微信')
->value('current_balance'), 0.001);
$this->assertEqualsWithDelta(0.00, (float) $db->table('fin_shops')
->where('company_name', '独立公司')
->value('shop_balance'), 0.001);
}
}