财务台账:增加当前余额并按账户状态同步金额 #437

Merged
shanshi merged 1 commits from feature/shanshi-ledger-current-balance-status-amounts into main 2026-10-21 16:47:17 +08:00
9 changed files with 242 additions and 44 deletions

View File

@ -99,7 +99,8 @@ class FinanceLedger extends Controller
$summaryQuery = clone $query;
$summary = [
'count' => (clone $query)->count(),
'balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
'current_balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
'balance' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2),
'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
];
@ -187,7 +188,7 @@ class FinanceLedger extends Controller
]);
}
public function updateAccountTodo(Request $request)
public function updateAccountTodo(Request $request, FinanceLedgerCalculator $calculator)
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
@ -240,7 +241,7 @@ class FinanceLedger extends Controller
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus): void {
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus, $calculator): void {
$todos = $connection->table('fin_account_todos');
$existing = $todos->where('account_short', $accountShort)->exists();
if ($existing) {
@ -260,6 +261,7 @@ class FinanceLedger extends Controller
'updated_by' => (string) user()->name,
'updated_at' => $now,
]);
$calculator->refreshAccountAmounts($accountShort);
}
});
@ -320,7 +322,7 @@ class FinanceLedger extends Controller
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateAccountStatus(Request $request): JsonResponse
public function updateAccountStatus(Request $request, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。');
@ -349,14 +351,18 @@ class FinanceLedger extends Controller
'updated_at' => $now,
];
if ($existing === null) {
$statuses->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
} else {
$statuses->where('account_short', $accountShort)->update($values);
}
$connection->transaction(function () use ($statuses, $existing, $values, $accountShort, $now, $calculator): void {
if ($existing === null) {
$statuses->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
} else {
$statuses->where('account_short', $accountShort)->update($values);
}
$calculator->refreshAccountAmounts($accountShort);
});
return response()->json([
'ok' => true,

View File

@ -8,7 +8,7 @@ CAIWU_BATCH_CALLER_DIR="$PWD"
export CAIWU_BATCH_CALLER_DIR
case "${1:-}" in
*:import|*:import-*|*:import:*|product:monthly-weighted-average:base) is_import=1 ;;
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run) ;;
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate) ;;
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
esac
cd "$REMOTE_DIR"

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use Illuminate\Support\Facades\DB;
/**
@ -10,7 +11,8 @@ use Illuminate\Support\Facades\DB;
* 计算口径固定在服务端,页面只读结果:
* 期末 = 期初 + 当日入账 - 当日出账
* 账户当前余额 = 最新一条流水期末
* 每日可出金额 = 当前余额 - 卡住金额
* 正常账户:可用金额 = 当前余额,不可用金额 = 0
* 异常账户:可用金额 = 0,不可用金额 = 当前余额
* 店铺余额 = 店铺所属公司名下账户当前余额合计(待财务确认)
*/
class FinanceLedgerCalculator
@ -150,11 +152,12 @@ class FinanceLedgerCalculator
->where('account_short', $account)
->update([
'current_balance' => round((float) $balance, 2),
'daily_available' => $connection->raw('ROUND(current_balance - blocked_amount, 2)'),
'updated_at' => $now,
]);
}
$this->refreshAccountAmounts();
$companies = $connection->table('fin_accounts')
->selectRaw('company_name, SUM(current_balance) AS balance')
->groupBy('company_name')
@ -175,6 +178,58 @@ class FinanceLedgerCalculator
];
}
/**
* 按账户状态回填可用金额和不可用金额。
*
* 正常账户:可用金额 = 当前余额,不可用金额 = 0;
* 异常账户:可用金额 = 0,不可用金额 = 当前余额。
*/
public function refreshAccountAmounts(?string $accountShort = null): int
{
$connection = $this->connection();
if ($accountShort !== null) {
$status = $connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->value('status');
$normal = $status === null
|| trim((string) $status) === ''
|| $status === FinanceLedgerAccountStatus::NORMAL;
return $connection->table('fin_accounts')
->where('account_short', $accountShort)
->update([
'daily_available' => $normal ? $connection->raw('current_balance') : 0,
'blocked_amount' => $normal ? 0 : $connection->raw('current_balance'),
'updated_at' => now(),
]);
}
$abnormalAccounts = $connection->table('fin_account_statuses')
->where('status', '<>', FinanceLedgerAccountStatus::NORMAL)
->pluck('account_short')
->filter()
->unique()
->values()
->all();
$normalCount = $connection->table('fin_accounts')->update([
'daily_available' => $connection->raw('current_balance'),
'blocked_amount' => 0,
'updated_at' => now(),
]);
if ($abnormalAccounts === []) {
return $normalCount;
}
return $normalCount + $connection->table('fin_accounts')
->whereIn('account_short', $abnormalAccounts)
->update([
'daily_available' => 0,
'blocked_amount' => $connection->raw('current_balance'),
'updated_at' => now(),
]);
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));

View File

@ -150,7 +150,8 @@ class FinanceLedgerPageData
$summaryQuery = clone $query;
$summary = [
'count' => (clone $query)->count(),
'balance' => round((float) (clone $summaryQuery)->sum('current_balance'), 2),
'current_balance' => round((float) (clone $summaryQuery)->sum('current_balance'), 2),
'balance' => round((float) (clone $summaryQuery)->sum('daily_available'), 2),
'blocked' => round((float) (clone $summaryQuery)->sum('blocked_amount'), 2),
];

View File

@ -84,8 +84,8 @@
.ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; }
.ledger-mapping-bulk .ledger-btn { height:1.95rem; }
.ledger-download-status { flex-basis:100%; color:var(--vip-success, #53793E); font-size:.68rem; font-weight:600; line-height:1.4; text-align:right; }
.ledger-summary { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.65rem; margin-bottom:.85rem; }
.ledger-summary__item { display:flex; flex-direction:column; justify-content:center; gap:.2rem; min-width:0; min-height:3.4rem; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
.ledger-summary { display:flex; flex-wrap:wrap; gap:.65rem; margin-bottom:.85rem; }
.ledger-summary__item { display:flex; flex:0 0 14rem; flex-direction:column; justify-content:center; gap:.2rem; width:14rem; min-width:14rem; height:3.4rem; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
.ledger-summary__item--available { background:#f3f7ee; }
.ledger-summary__item--blocked { background:#fff5f5; }
.ledger-summary__item--link { color:inherit; text-decoration:none; transition:border-color .15s ease, box-shadow .15s ease; }
@ -170,8 +170,7 @@
.ledger-toast.is-error { border-color:var(--vip-danger, #B80000); color:var(--vip-danger, #B80000); }
.ledger-source-tag { display:inline-flex; align-items:center; padding:.1rem .4rem; border-radius:.25rem; background:#f4f1eb; color:var(--vip-content-muted, #595959); font-size:.68rem; }
.ledger-mono { font-variant-numeric:tabular-nums; }
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } .ledger-summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .ledger-summary { grid-template-columns:1fr; } }
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } }
</style>
<div class="ledger-hero">
@ -217,7 +216,7 @@
<input type="checkbox" role="switch" aria-label="编辑模式" aria-checked="false" data-ledger-edit-mode-toggle @disabled(! $canEditAccounts)>
<span class="ledger-edit-switch__track" aria-hidden="true"><span class="ledger-edit-switch__thumb"></span></span>
</label>
<div class="ledger-column-picker" data-ledger-column-picker data-column-storage-key="caiwu.financial-ledger.accounts.preferences.v1.{{ auth()->id() }}.{{ company_id() }}">
<div class="ledger-column-picker" data-ledger-column-picker data-column-storage-key="caiwu.financial-ledger.accounts.preferences.v2.{{ auth()->id() }}.{{ company_id() }}">
<button class="ledger-btn" type="button" data-ledger-column-toggle aria-expanded="false"><span class="material-icons-outlined" aria-hidden="true">view_column</span>列设置</button>
<div class="ledger-column-panel" data-ledger-column-panel role="group" aria-label="选择账户总表展示列" hidden>
<p class="ledger-column-help">拖动字段可调整列顺序;拖动表头右侧边界可调整列宽,双击边界恢复默认。</p>
@ -230,13 +229,13 @@
</form>
<div class="ledger-summary">
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
<div class="ledger-summary__item ledger-summary__item--available"><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
<a class="ledger-summary__item ledger-summary__item--blocked ledger-summary__item--link" href="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="查看账户异常待办"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></a>
<div class="ledger-summary__item"><span>每日可出金额口径</span><b>可用金额 − 不可用金额</b></div>
<div class="ledger-summary__item ledger-summary__item--available" data-ledger-summary-available><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
<a class="ledger-summary__item ledger-summary__item--blocked ledger-summary__item--link" data-ledger-summary-blocked href="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="查看账户异常待办"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></a>
<div class="ledger-summary__item"><span>金额口径</span><b>正常计可用,异常计不可用</b></div>
</div>
<div class="ledger-table-wrap ledger-accounts-table-wrap">
<table class="ledger-table ledger-accounts-table">
<thead><tr><th>公司名称</th><th>账户简称</th><th>账户类型</th><th class="ledger-status-column">账户状态</th><th>地区</th><th>账号</th><th>开户行</th><th>开户城市/网点</th><th class="ledger-plan-column">公司规划</th><th class="ledger-keep-column">账户是否保留</th><th>后4位</th><th>单笔限额</th><th>日限额</th><th>月限额</th><th>年限额</th><th class="num">可用金额</th><th class="num">不可用金额</th><th class="num">每日可出</th><th class="num">预计出清天数</th><th>提额目标</th><th>提额状态</th><th>提额动作</th><th>已做动作</th><th>下一步</th><th>备注</th><th>责任人</th></tr></thead>
<thead><tr><th>公司名称</th><th>账户简称</th><th>账户类型</th><th class="ledger-status-column">账户状态</th><th>地区</th><th>账号</th><th>开户行</th><th>开户城市/网点</th><th class="ledger-plan-column">公司规划</th><th class="ledger-keep-column">账户是否保留</th><th>后4位</th><th>单笔限额</th><th>日限额</th><th>月限额</th><th>年限额</th><th class="num">当前余额</th><th class="num">可用金额</th><th class="num">不可用金额</th><th class="num">每日可出</th><th class="num">预计出清天数</th><th>提额目标</th><th>提额状态</th><th>提额动作</th><th>已做动作</th><th>下一步</th><th>备注</th><th>责任人</th></tr></thead>
<tbody>
@forelse ($ledgerAccounts as $row)
<tr>
@ -290,10 +289,10 @@
</select>
@endif
</td>
<td>{{ $row->account_last4 }}</td><td>{{ $row->single_limit }}</td><td>{{ $row->daily_limit }}</td><td>{{ $row->monthly_limit }}</td><td>{{ $row->yearly_limit }}</td><td class="num">{{ $money($row->current_balance) }}</td><td class="num">{{ $money($row->blocked_amount) }}</td><td class="num">{{ $money($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td>{{ $row->raise_target }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
<td>{{ $row->account_last4 }}</td><td>{{ $row->single_limit }}</td><td>{{ $row->daily_limit }}</td><td>{{ $row->monthly_limit }}</td><td>{{ $row->yearly_limit }}</td><td class="num" data-account-current-balance="{{ (float) $row->current_balance }}">{{ $money($row->current_balance) }}</td><td class="num" data-account-available-amount>{{ $money($row->daily_available) }}</td><td class="num" data-account-blocked-amount>{{ $money($row->blocked_amount) }}</td><td class="num" data-account-daily-available-amount>{{ $money($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td>{{ $row->raise_target }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
</tr>
@empty
<tr><td colspan="26"><div class="ledger-empty">没有匹配的账户数据。</div></td></tr>
<tr><td colspan="27"><div class="ledger-empty">没有匹配的账户数据。</div></td></tr>
@endforelse
</tbody>
</table>
@ -968,6 +967,41 @@
item.closest('td')?.setAttribute('data-account-should-keep', value);
}
});
if (field === 'status') {
const row = select.closest('tr');
const currentCell = row?.querySelector('[data-account-current-balance]');
const currentAmount = Number(currentCell?.dataset.accountCurrentBalance || 0);
const currentText = currentCell?.textContent?.trim() || '0.00';
const nextNormal = value === 'normal';
const previousNormal = previous === 'normal';
const availableCell = row?.querySelector('[data-account-available-amount]');
const blockedCell = row?.querySelector('[data-account-blocked-amount]');
const dailyAvailableCell = row?.querySelector('[data-account-daily-available-amount]');
if (availableCell) availableCell.textContent = nextNormal ? currentText : '0.00';
if (blockedCell) blockedCell.textContent = nextNormal ? '0.00' : currentText;
if (dailyAvailableCell) dailyAvailableCell.textContent = nextNormal ? currentText : '0.00';
if (nextNormal !== previousNormal) {
const formatSummaryAmount = (amount) => amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const adjustSummary = (card, delta) => {
const valueNode = card?.querySelector('b');
if (!valueNode) return;
const current = Number(valueNode.textContent.replace(/,/g, '')) || 0;
valueNode.textContent = formatSummaryAmount(Math.max(0, current + delta));
};
adjustSummary(
page.querySelector('[data-ledger-summary-available]'),
nextNormal ? currentAmount : -currentAmount,
);
adjustSummary(
page.querySelector('[data-ledger-summary-blocked]'),
nextNormal ? -currentAmount : currentAmount,
);
}
}
notify(successLabel + ':' + (payload.label || ''));
} catch (error) {
select.value = previous;

View File

@ -13,8 +13,8 @@
.pending-balance-hero p { margin:0; color:var(--vip-content-muted, #595959); line-height:1.55; }
.pending-balance-back { display:inline-flex; align-items:center; gap:.35rem; color:var(--vip-content-accent-dark, #856632); font-weight:600; text-decoration:none; }
.pending-balance-back:hover { color:#6A5129; }
.pending-balance-summary { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.65rem; margin-bottom:.9rem; }
.pending-balance-card { min-width:0; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
.pending-balance-summary { display:flex; flex-wrap:wrap; gap:.65rem; margin-bottom:.9rem; }
.pending-balance-card { display:flex; flex:0 0 14rem; flex-direction:column; justify-content:center; width:14rem; min-width:14rem; height:3.4rem; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
.pending-balance-card.is-available { background:#f3f7ee; }
.pending-balance-card.is-blocked { background:#fff5f5; }
.pending-balance-card span { display:block; overflow:hidden; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
@ -42,6 +42,8 @@
.pending-balance-table td:last-child { border-right:0; }
.pending-balance-table tbody tr:last-child td { border-bottom:0; }
.pending-balance-table tbody tr:hover td { background:#fcfaf4; }
.pending-balance-table tfoot td { position:sticky; bottom:0; z-index:5; border-top:1px solid var(--vip-border, #D8D2C7); background:var(--vip-content-accent-soft, #F7F0DE); font-weight:700; }
.pending-balance-table tfoot .num { text-align:right; }
.pending-balance-table .num { font-variant-numeric:tabular-nums; text-align:right; }
.pending-balance-sort { display:inline-flex; align-items:center; justify-content:center; gap:.2rem; color:inherit; text-decoration:none; }
.pending-balance-sort:hover { color:var(--vip-content-accent-dark, #856632); }
@ -81,9 +83,8 @@
.ledger-page-box__current { min-width:2.2rem; padding:0 .15rem; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; }
.pending-balance-page__status { color:var(--vip-content-muted, #595959); font-size:.68rem; }
@media (max-width:1100px) { .pending-balance-filter-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
@media (max-width:900px) { .pending-balance-summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width:700px) { .pending-balance-filter-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width:560px) { .pending-balance-page { margin:1rem .75rem 1.5rem; } .pending-balance-summary, .pending-balance-filter-grid { grid-template-columns:1fr; } }
@media (max-width:560px) { .pending-balance-page { margin:1rem .75rem 1.5rem; } .pending-balance-filter-grid { grid-template-columns:1fr; } }
</style>
<section class="pending-balance-page" aria-labelledby="pending-balance-title">
@ -202,6 +203,7 @@
<th data-pending-column-key="account_short">账户简称</th>
<th data-pending-column-key="account_type">账户类型</th>
<th data-pending-column-key="account_status">账户状态</th>
<th data-pending-column-key="current_balance">当前余额</th>
<th data-pending-column-key="account_no">账号</th>
<th data-pending-column-key="bank_name">开户行</th>
<th data-pending-column-key="bank_city_branch">开户城市/网点</th>
@ -237,6 +239,7 @@
<td>{{ $row->account_short ?: '未填写' }}</td>
<td>{{ $row->account_type ?: '未填写' }}</td>
<td><span class="pending-balance-status is-{{ $row->account_status }}">{{ \App\Utilities\FinanceLedgerAccountStatus::label($row->account_status) }}</span></td>
<td class="num" data-pending-current-balance="{{ $money($row->current_balance) }}">{{ $money($row->current_balance) }}</td>
<td>{{ $row->account_no ?: '未填写' }}</td>
<td>{{ $row->bank_name ?: '未填写' }}</td>
<td>{{ $row->bank_city_branch ?: '未填写' }}</td>
@ -271,9 +274,16 @@
</td>
</tr>
@empty
<tr><td colspan="14" class="pending-balance-empty">当前没有账户异常待办</td></tr>
<tr><td colspan="15" class="pending-balance-empty">当前没有账户异常待办</td></tr>
@endforelse
</tbody>
<tfoot>
<tr>
<td colspan="4">当前筛选合计</td>
<td class="num" data-pending-current-balance-total="{{ $money($summary['current_balance'] ?? 0) }}">{{ $money($summary['current_balance'] ?? 0) }}</td>
<td colspan="10" aria-label="其余列无金额合计"></td>
</tr>
</tfoot>
</table>
</div>
@ -316,6 +326,7 @@
account_short: 92,
account_type: 72,
account_status: 78,
current_balance: 112,
account_no: 180,
bank_name: 250,
bank_city_branch: 180,

View File

@ -23,6 +23,9 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'account_no' => '6222000000000001',
'bank_name' => '测试银行',
'company_plan' => '保留',
'current_balance' => 321.45,
'blocked_amount' => 0,
'daily_available' => 321.45,
]);
}
@ -123,7 +126,12 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('不可用金额合计')
->assertSee('href="' . $this->unavailableBalancesUrl() . '"', false)
->assertSee('查看账户异常待办')
->assertSee('可用金额 − 不可用金额')
->assertSee('正常计可用,异常计不可用')
->assertSee('<th class="num">当前余额</th><th class="num">可用金额</th><th class="num">不可用金额</th>', false)
->assertSee('data-ledger-summary-available', false)
->assertSee('data-account-current-balance="321.45"', false)
->assertSee('data-account-available-amount', false)
->assertSee('data-account-blocked-amount', false)
->assertSee('未填写')
->assertDontSee('data-account-filter-state', false)
->assertSee('value="normal" selected', false);
@ -141,8 +149,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'bank_name' => '待办测试银行',
'bank_city_branch' => '上海 / 待办测试支行',
'current_balance' => 100,
'blocked_amount' => 25.5,
'daily_available' => 74.5,
'blocked_amount' => 100,
'daily_available' => 0,
'owner' => '待办负责人',
'next_action' => '核实冻结原因',
'remark' => '独立待办备注',
@ -158,8 +166,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'company_name' => '正常状态测试公司',
'account_short' => '正常状态测试账户',
'current_balance' => 100,
'blocked_amount' => 50,
'daily_available' => 50,
'blocked_amount' => 0,
'daily_available' => 100,
],
[
'company_name' => '待办测试公司二',
@ -168,9 +176,9 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'account_type' => '个人卡',
'bank_name' => '待办测试银行二',
'bank_city_branch' => '杭州 / 待办测试支行',
'current_balance' => 100,
'blocked_amount' => 10,
'daily_available' => 90,
'current_balance' => 250,
'blocked_amount' => 250,
'daily_available' => 0,
],
];
foreach ($accounts as $account) {
@ -206,7 +214,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('待办测试公司')
->assertSee('data-pending-account="待办测试账户"', false)
->assertSee('不可用金额合计')
->assertSee('35.50')
->assertSee('350.00')
->assertSee('限额')
->assertSee('网商银行')
->assertSee('6222000000000099')
@ -216,6 +224,10 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('办理方式')
->assertSee('当前进度')
->assertSee('是否完成')
->assertSee('<th data-pending-column-key="current_balance">当前余额</th>', false)
->assertSee('data-pending-current-balance="100.00"', false)
->assertSee('data-pending-current-balance-total="350.00"', false)
->assertSee('当前筛选合计')
->assertSee('name="pending_short"', false)
->assertSee('name="pending_type"', false)
->assertSee('name="pending_status"', false)
@ -288,8 +300,17 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get($filteredUrl)
->assertOk()
->assertSee('data-pending-account="待办测试账户"', false)
->assertSee('data-pending-current-balance-total="100.00"', false)
->assertDontSee('data-pending-account="待办测试账户二"', false);
$connection->table('fin_accounts')
->where('account_short', '待办测试账户')
->update(['current_balance' => 180]);
$this->loginAs()->get($filteredUrl)
->assertOk()
->assertSee('data-pending-current-balance="180.00"', false)
->assertSee('data-pending-current-balance-total="180.00"', false);
$sortResponse = $this->loginAs()->get(
$this->unavailableBalancesUrl() . '?pending_sort=started_at&pending_direction=desc'
)->assertOk()->assertSee('aria-sort="descending"', false);
@ -314,6 +335,12 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'account_short' => '待办测试账户',
'status' => 'normal',
], 'business_mirror');
$this->assertDatabaseHas('fin_accounts', [
'account_short' => '待办测试账户',
'current_balance' => 180,
'daily_available' => 180,
'blocked_amount' => 0,
], 'business_mirror');
$this->loginAs()->get($this->unavailableBalancesUrl())
->assertOk()
@ -511,11 +538,23 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertOk()
->assertJsonPath('status', 'limited')
->assertJsonPath('label', '限额');
$this->assertDatabaseHas('fin_accounts', [
'account_short' => '测试账户',
'current_balance' => 321.45,
'daily_available' => 0,
'blocked_amount' => 321.45,
], 'business_mirror');
$this->loginAs()->postJson($this->statusUrl(), [
'account_short' => '测试账户',
'status' => 'normal',
])->assertOk()->assertJsonPath('label', '正常');
$this->assertDatabaseHas('fin_accounts', [
'account_short' => '测试账户',
'current_balance' => 321.45,
'daily_available' => 321.45,
'blocked_amount' => 0,
], 'business_mirror');
$this->assertDatabaseCount('fin_account_statuses', 1, 'business_mirror');
$this->assertDatabaseHas('fin_account_statuses', [

View File

@ -73,4 +73,24 @@ class FinanceBatchIsolationTest extends TestCase
rmdir($directory);
}
}
public function test_finance_ledger_recalculation_runs_in_the_isolated_batch_profile(): void
{
$directory = sys_get_temp_dir() . '/caiwu-ledger-batch-' . bin2hex(random_bytes(6));
mkdir($directory);
try {
$process = $this->runBatch($directory, ['finance-ledger:recalculate']);
$this->assertTrue($process->isSuccessful(), $process->getErrorOutput());
$arguments = file($directory . '/called', FILE_IGNORE_NEW_LINES);
$this->assertContains('batch', $arguments);
$this->assertSame('finance-ledger:recalculate', end($arguments));
$this->assertContains('--no-deps', $arguments);
$this->assertSame('image=123456789abc', $arguments[0]);
} finally {
foreach (glob($directory . '/*') as $path) {
unlink($path);
}
rmdir($directory);
}
}
}

View File

@ -40,6 +40,13 @@ class FinanceLedgerCalculatorTest extends TestCase
$table->decimal('opening_balance', 18, 2)->default(0);
$table->timestamp('updated_at')->nullable();
});
$schema->create('fin_account_statuses', function (Blueprint $table): void {
$table->increments('id');
$table->string('account_short')->unique();
$table->string('status');
$table->string('updated_by')->nullable();
$table->timestamps();
});
$schema->create('fin_transactions', function (Blueprint $table): void {
$table->increments('id');
$table->date('txn_date');
@ -83,6 +90,10 @@ class FinanceLedgerCalculatorTest extends TestCase
['account_short' => '账户A', 'opening_date' => '2026-10-17', 'opening_balance' => 1000],
['account_short' => '账户B', 'opening_date' => '2026-10-17', 'opening_balance' => 500],
]);
$db->table('fin_account_statuses')->insert([
['account_short' => '账户A', 'status' => 'normal', 'updated_by' => 'Test User'],
['account_short' => '账户B', 'status' => 'limited', 'updated_by' => 'Test User'],
]);
$db->table('fin_transactions')->insert([
['txn_date' => '2026-10-17', 'account_short' => '', 'source' => 'shop', 'source_value' => '店铺A', 'income' => 100, 'expense' => 0],
['txn_date' => '2026-10-18', 'account_short' => '账户A', 'source' => 'shop', 'source_value' => '店铺A', 'income' => 0, 'expense' => 50],
@ -118,9 +129,30 @@ class FinanceLedgerCalculatorTest extends TestCase
$this->assertEqualsWithDelta(1050.00, (float) $rows[1]->closing_balance, 0.001);
$this->assertEqualsWithDelta(500.00, (float) $rows[2]->closing_balance, 0.001);
$accounts = DB::connection('business_mirror')->table('fin_accounts')->pluck('current_balance', 'account_short');
$this->assertEqualsWithDelta(1050.00, (float) $accounts['账户A'], 0.001);
$this->assertEqualsWithDelta(500.00, (float) $accounts['账户B'], 0.001);
$accounts = DB::connection('business_mirror')->table('fin_accounts')->get()->keyBy('account_short');
$this->assertEqualsWithDelta(1050.00, (float) $accounts['账户A']->current_balance, 0.001);
$this->assertEqualsWithDelta(1050.00, (float) $accounts['账户A']->daily_available, 0.001);
$this->assertEqualsWithDelta(0.00, (float) $accounts['账户A']->blocked_amount, 0.001);
$this->assertEqualsWithDelta(500.00, (float) $accounts['账户B']->current_balance, 0.001);
$this->assertEqualsWithDelta(0.00, (float) $accounts['账户B']->daily_available, 0.001);
$this->assertEqualsWithDelta(500.00, (float) $accounts['账户B']->blocked_amount, 0.001);
$this->assertEqualsWithDelta(1050.00, (float) DB::connection('business_mirror')->table('fin_shops')->value('shop_balance'), 0.001);
}
public function test_account_amounts_follow_status_changes(): void
{
$calculator = app(FinanceLedgerCalculator::class);
$calculator->recalculate();
DB::connection('business_mirror')->table('fin_account_statuses')
->where('account_short', '账户B')
->update(['status' => 'normal', 'updated_by' => 'Test User']);
$calculator->refreshAccountAmounts('账户B');
$account = DB::connection('business_mirror')->table('fin_accounts')
->where('account_short', '账户B')
->first();
$this->assertEqualsWithDelta(500.00, (float) $account->daily_available, 0.001);
$this->assertEqualsWithDelta(0.00, (float) $account->blocked_amount, 0.001);
}
}