CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerFlowEditor.php
shanshi b959cef955 feat: 支持账户资金汇总直接编辑每日流水 (#1063)
账户资金汇总新增逐行编辑入口,可直接选择日期打开每日流水编辑;目标日期无流水时创建手动流水,并统一回算账户余额、每日流水及期间汇总。
2026-11-10 20:24:54 +08:00

260 lines
9.4 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Validation\ValidationException;
class FinanceLedgerFlowEditor
{
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly FinanceLedgerCalculator $calculator,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function update(array $data): array
{
$date = $this->date($data['flow_date'] ?? null);
$accountShort = $this->text($data['account_short'] ?? '');
$openingCents = $this->cents($data['opening_balance'] ?? 0);
$inflowCents = $this->cents($data['inflow'] ?? 0);
$outflowCents = $this->cents($data['outflow'] ?? 0);
$closingCents = $this->cents($data['closing_balance'] ?? 0);
$opening = $openingCents / 100;
$inflow = $inflowCents / 100;
$outflow = $outflowCents / 100;
$closing = $closingCents / 100;
$remark = $this->nullableText($data['remark'] ?? null);
if ($openingCents + $inflowCents - $outflowCents !== $closingCents) {
throw ValidationException::withMessages([
'closing_balance' => '期末余额必须等于期初余额 + 当日入账 - 当日出账。',
]);
}
$this->connection->transaction(function () use (
$date,
$accountShort,
$opening,
$inflow,
$outflow,
$closing,
$remark,
): void {
$account = $this->connection->table('fin_accounts')
->where('account_short', $accountShort)
->lockForUpdate()
->first();
if ($account === null) {
throw ValidationException::withMessages([
'account_short' => '账户简称不存在,请从账户总表中选择。',
]);
}
$before = $this->existingFlow($date, $accountShort);
$values = [
'opening_balance' => $opening,
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $closing,
'remark' => $remark,
'updated_by' => (string) (user()?->name ?? ''),
'updated_at' => now(),
];
$existing = $this->connection->table('fin_daily_flow_overrides')
->where('flow_date', $date)
->where('account_short', $accountShort)
->first();
if ($existing === null) {
$this->connection->table('fin_daily_flow_overrides')->insert($values + [
'flow_date' => $date,
'account_short' => $accountShort,
'created_at' => now(),
]);
} else {
$this->connection->table('fin_daily_flow_overrides')
->where('id', $existing->id)
->update($values);
}
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => 'daily_flow',
'entity_key' => $date . '|' . $accountShort,
'changes' => json_encode([
'before' => $before,
'after' => [
'flow_date' => $date,
'account_short' => $accountShort,
'opening_balance' => $opening,
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $closing,
'remark' => $remark,
],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? ''),
'created_at' => now(),
]);
$this->calculator->recalculate();
});
return $this->existingFlow($date, $accountShort) ?? [];
}
/**
* @return array<string,mixed>|null
*/
public function reset(string $flowDate, string $accountShort): ?array
{
$date = $this->date($flowDate);
$accountShort = $this->text($accountShort);
$this->connection->transaction(function () use ($date, $accountShort): void {
$before = $this->existingFlow($date, $accountShort);
$deleted = $this->connection->table('fin_daily_flow_overrides')
->where('flow_date', $date)
->where('account_short', $accountShort)
->delete();
if ($deleted > 0) {
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => 'daily_flow_reset',
'entity_key' => $date . '|' . $accountShort,
'changes' => json_encode([
'before' => $before,
'after' => null,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? ''),
'created_at' => now(),
]);
}
$this->calculator->recalculate();
});
return $this->existingFlow($date, $accountShort);
}
/** @return array<string,mixed>|null */
public function existingFlow(string $date, string $accountShort): ?array
{
$row = $this->connection->table('fin_daily_flows')
->whereDate('flow_date', $date)
->where('account_short', $accountShort)
->first();
if ($row === null) {
return null;
}
return [
'id' => (int) $row->id,
'flow_date' => substr((string) $row->flow_date, 0, 10),
'company_name' => $row->company_name,
'account_short' => $row->account_short,
'opening_balance' => round((float) $row->opening_balance, 2),
'inflow' => round((float) $row->inflow, 2),
'outflow' => round((float) $row->outflow, 2),
'closing_balance' => round((float) $row->closing_balance, 2),
'remark' => $row->remark,
'manual_override' => (bool) ($row->manual_override ?? false),
'updated_by' => $row->updated_by ?? null,
];
}
/**
* 汇总页可以在尚无每日流水时直接选择日期建立手动流水。
*
* @return array<string,mixed>
*/
public function editableFlow(string $flowDate, string $accountShort): array
{
$date = $this->date($flowDate);
$accountShort = $this->text($accountShort);
$existing = $this->existingFlow($date, $accountShort);
if ($existing !== null) {
return $existing;
}
$account = $this->connection->table('fin_accounts')
->where('account_short', $accountShort)
->orderBy('id')
->first(['account_short', 'company_name']);
if ($account === null) {
throw ValidationException::withMessages([
'account_short' => '账户简称不存在,请从账户总表中选择。',
]);
}
$latest = $this->connection->table('fin_daily_flows')
->where('account_short', $accountShort)
->where('flow_date', '<', $date)
->orderByDesc('flow_date')
->orderByDesc('id')
->first(['closing_balance']);
$openingBalance = $latest === null
? round((float) ($this->connection->table('fin_account_opening')
->where('account_short', $accountShort)
->where('opening_date', '<=', $date)
->orderByDesc('opening_date')
->value('opening_balance') ?? 0), 2)
: round((float) $latest->closing_balance, 2);
return [
'id' => null,
'flow_date' => $date,
'company_name' => (string) ($account->company_name ?? ''),
'account_short' => (string) $account->account_short,
'opening_balance' => $openingBalance,
'inflow' => 0.0,
'outflow' => 0.0,
'closing_balance' => $openingBalance,
'remark' => '新建手动流水',
'manual_override' => false,
'updated_by' => null,
];
}
private function date(mixed $value): string
{
$text = trim((string) $value);
$date = CarbonImmutable::createFromFormat('!Y-m-d', $text);
if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== $text) {
throw ValidationException::withMessages([
'flow_date' => '流水日期必须使用 YYYY-MM-DD 格式。',
]);
}
return $text;
}
private function cents(mixed $value): int
{
return (int) round((float) $value * 100);
}
private function text(mixed $value): string
{
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
}
private function nullableText(mixed $value): ?string
{
$text = $this->text($value);
return $text !== '' ? $text : null;
}
}