CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php

364 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Illuminate\Support\Facades\DB;
/**
* 按账户滚动计算每日资金流水,并回写账户当前余额、每日可出金额和店铺余额。
*
* 计算口径固定在服务端,页面只读结果:
* 期末 = 期初 + 当日入账 - 当日出账
* 账户当前余额 = 最新一条流水期末
* 正常账户:可用金额 = 当前余额,不可用金额 = 0
* 异常账户:可用金额 = 0,不可用金额 = 当前余额
* 店铺余额 = 店铺所属公司名下账户当前余额合计(待财务确认)
*/
class FinanceLedgerCalculator
{
public function recalculate(): array
{
$connection = $this->connection();
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
$now = now();
$opening = $connection->table('fin_account_opening')
->orderBy('opening_date')
->orderBy('id')
->get()
->groupBy('account_short')
->map(static fn ($rows) => $rows->last());
$accountCompanies = FinanceLedgerIndependentAccounts::exclude(
$connection->table('fin_accounts'),
)
->select(['account_short', 'company_name'])
->whereNotNull('account_short')
->where('account_short', '<>', '')
->orderBy('id')
->get()
->groupBy('account_short')
->map(static fn ($rows) => (string) ($rows->first()->company_name ?? ''));
$mapping = $connection->table('fin_account_map')
->whereNotNull('account_short')
->where('account_short', '<>', '')
->get(['source', 'source_value', 'account_short'])
->keyBy(static fn ($row): string => $row->source . "\0" . $row->source_value);
$transactions = $connection->table('fin_transactions')
->orderBy('txn_date')
->orderBy('id')
->get(['txn_date', 'account_short', 'source', 'source_value', 'income', 'expense']);
$schema = $connection->getSchemaBuilder();
$manualOverrides = $schema->hasTable('fin_daily_flow_overrides')
? $connection->table('fin_daily_flow_overrides')->get()->keyBy(
static fn (object $row): string => substr((string) $row->flow_date, 0, 10)
. "\0"
. (string) $row->account_short,
)
: collect();
$hasManualColumns = $schema->hasColumn('fin_daily_flows', 'manual_override');
$dailyByAccount = [];
$balances = [];
foreach ($transactions as $transaction) {
$mapKey = $transaction->source . "\0" . $transaction->source_value;
$account = (string) ($mapping->get($mapKey)->account_short ?? '');
if ($account === '') {
$account = trim((string) $transaction->account_short);
}
if ($account === '' || ! $accountCompanies->has($account)) {
continue;
}
$date = (string) $transaction->txn_date;
$dailyByAccount[$account][$date] ??= [
'flow_date' => $date,
'inflow' => 0.0,
'outflow' => 0.0,
];
$dailyByAccount[$account][$date]['inflow'] += (float) $transaction->income;
$dailyByAccount[$account][$date]['outflow'] += (float) $transaction->expense;
}
$flows = [];
$latest = [];
foreach ($accountCompanies as $account => $company) {
$account = (string) $account;
$openingRow = $opening->get($account);
$balance = $openingRow === null ? 0.0 : (float) $openingRow->opening_balance;
$start = $openingRow === null
? $openingDate
: (string) $openingRow->opening_date;
$rows = array_values($dailyByAccount[$account] ?? []);
usort($rows, static fn (array $a, array $b): int => strcmp($a['flow_date'], $b['flow_date']));
$openingFlow = [
'flow_date' => $start,
'company_name' => $company,
'account_short' => $account,
'opening_balance' => round($balance, 2),
'inflow' => 0.0,
'outflow' => 0.0,
'closing_balance' => round($balance, 2),
'remark' => '期初',
'created_at' => $now,
'updated_at' => $now,
];
if ($hasManualColumns) {
$openingFlow['manual_override'] = false;
$openingFlow['updated_by'] = null;
}
$flows[] = $openingFlow;
$openingIndex = array_key_last($flows);
$dates = array_values(array_unique([
...array_column($rows, 'flow_date'),
...$manualOverrides
->filter(static fn (object $row): bool => (string) $row->account_short === $account)
->map(static fn (object $row): string => substr((string) $row->flow_date, 0, 10))
->values()
->all(),
]));
sort($dates);
$rowsByDate = [];
foreach ($rows as $row) {
$rowsByDate[(string) $row['flow_date']] = $row;
}
foreach ($dates as $date) {
if ($date < $start) {
continue;
}
$override = $manualOverrides->get($date . "\0" . $account);
if ($override !== null) {
$openingBalance = round((float) $override->opening_balance, 2);
$inflow = round((float) $override->inflow, 2);
$outflow = round((float) $override->outflow, 2);
$balance = round((float) $override->closing_balance, 2);
$flow = [
'flow_date' => $date,
'company_name' => $company,
'account_short' => $account,
'opening_balance' => $openingBalance,
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $balance,
'remark' => $override->remark !== null ? (string) $override->remark : '手动调整',
'created_at' => $now,
'updated_at' => $now,
];
if ($hasManualColumns) {
$flow['manual_override'] = true;
$flow['updated_by'] = $override->updated_by !== null ? (string) $override->updated_by : null;
}
if ($date === $start) {
$flows[$openingIndex] = $flow;
} else {
$flows[] = $flow;
}
continue;
}
$row = $rowsByDate[$date] ?? null;
if ($row === null) {
continue;
}
$inflow = round((float) $row['inflow'], 2);
$outflow = round((float) $row['outflow'], 2);
if (abs($inflow) < 0.005 && abs($outflow) < 0.005) {
continue;
}
if ($date === $start) {
// 期初日发生动账时合并到同一行,避免唯一键冲突。
$balance = round($balance + $inflow - $outflow, 2);
$flows[$openingIndex]['inflow'] = $inflow;
$flows[$openingIndex]['outflow'] = $outflow;
$flows[$openingIndex]['closing_balance'] = $balance;
$flows[$openingIndex]['remark'] = null;
continue;
}
$balance = round($balance + $inflow - $outflow, 2);
$flow = [
'flow_date' => $date,
'company_name' => $company,
'account_short' => $account,
'opening_balance' => round($balance - $inflow + $outflow, 2),
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $balance,
'remark' => null,
'created_at' => $now,
'updated_at' => $now,
];
if ($hasManualColumns) {
$flow['manual_override'] = false;
$flow['updated_by'] = null;
}
$flows[] = $flow;
}
$latest[$account] = $balance;
}
$connection->transaction(function () use ($connection, $flows, $latest, $now): void {
$connection->table('fin_daily_flows')->delete();
foreach (array_chunk($flows, 500) as $chunk) {
$connection->table('fin_daily_flows')->insert($chunk);
}
$this->updateBalances(
$connection,
'fin_accounts',
'account_short',
'current_balance',
$latest,
$now,
);
$this->refreshAccountAmounts();
$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,
'fin_shops',
'company_name',
'shop_balance',
$companies,
$now,
);
});
return [
'accounts' => count($latest),
'flows' => count($flows),
'opening_date' => $openingDate,
'balance_sum' => round(array_sum($latest), 2),
];
}
/**
* 按账户状态回填可用金额和不可用金额。
*
* 正常账户:可用金额 = 当前余额,不可用金额 = 0;
* 异常账户:可用金额 = 0,不可用金额 = 当前余额。
*/
public function refreshAccountAmounts(?string $accountShort = null): int
{
$connection = $this->connection();
if ($accountShort !== null) {
if (FinanceLedgerIndependentAccounts::contains($accountShort)) {
return 0;
}
$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()
->reject(static fn (mixed $account): bool => FinanceLedgerIndependentAccounts::contains($account))
->unique()
->values()
->all();
$normalQuery = FinanceLedgerIndependentAccounts::exclude($connection->table('fin_accounts'));
$normalCount = $normalQuery->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'));
}
private function updateBalances(
$connection,
string $table,
string $keyColumn,
string $balanceColumn,
array $balances,
$now,
): void {
foreach (array_chunk($balances, 500, true) as $chunk) {
if ($chunk === []) {
continue;
}
$caseClauses = [];
foreach ($chunk as $key => $balance) {
$caseClauses[] = 'WHEN '
. $connection->getPdo()->quote((string) $key)
. ' THEN '
. number_format(round((float) $balance, 2), 2, '.', '');
}
$connection->table($table)
->whereIn($keyColumn, array_keys($chunk))
->update([
$balanceColumn => $connection->raw(
"CASE `{$keyColumn}` " . implode(' ', $caseClauses)
. " ELSE `{$balanceColumn}` END"
),
'updated_at' => $now,
]);
}
}
}