feat: 隔离朱俊祥月度账单账户
This commit is contained in:
parent
e0f12d7a2b
commit
b0fdeb8893
@ -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);
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
|
||||
|
||||
36
app/Utilities/FinanceLedgerIndependentAccounts.php
Normal file
36
app/Utilities/FinanceLedgerIndependentAccounts.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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} 的有效交易,已保留原有动账明细。");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 === '') {
|
||||
|
||||
@ -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 ?? '');
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user