CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerIndependentTransactionEditor.php

429 lines
16 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerIndependentAccounts;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class FinanceLedgerIndependentTransactionEditor
{
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function update(int $id, array $data): array
{
$date = $this->date($data['txn_date'] ?? null);
$incomeCents = $this->cents($data['income'] ?? 0);
$expenseCents = $this->cents($data['expense'] ?? 0);
if (($incomeCents > 0) === ($expenseCents > 0)) {
throw ValidationException::withMessages([
'income' => '收入和支出只能填写一项。',
]);
}
return $this->connection->transaction(function () use (
$id,
$date,
$incomeCents,
$expenseCents,
$data,
): array {
$before = $this->activeRow($id, true);
$accountShort = (string) $before->account_short;
$customFields = $this->customFields($accountShort);
$customBefore = $this->customValues($id, $customFields);
$customAfter = $this->submittedCustomValues(
$data['custom_values'] ?? null,
$customFields,
$customBefore,
);
$now = now();
$income = $incomeCents / 100;
$expense = $expenseCents / 100;
$values = [
'txn_date' => $date,
'counterparty_info' => $this->nullableText($data['counterparty_info'] ?? null, 255),
'detail' => $this->nullableText($data['detail'] ?? null, 4000),
'income' => $income,
'expense' => $expense,
'balance' => $this->nullableMoney($data['balance'] ?? null),
'approval_no' => $this->nullableText($data['approval_no'] ?? null, 100),
'payment_remark' => $this->nullableText($data['payment_remark'] ?? null, 500),
'source' => trim((string) ($data['source'] ?? $before->source)),
'updated_by' => (string) (user()?->name ?? ''),
'updated_at' => $now,
];
$this->connection->table('fin_transactions')->where('id', $id)->update($values);
foreach ($customFields as $fieldKey => $field) {
$value = $customAfter[$fieldKey] ?? null;
if ($value === null) {
$this->connection->table('fin_transaction_custom_values')
->where('custom_field_id', (int) $field['id'])
->where('transaction_id', $id)
->delete();
continue;
}
$this->connection->table('fin_transaction_custom_values')->updateOrInsert(
[
'custom_field_id' => (int) $field['id'],
'transaction_id' => $id,
],
[
'value' => $value,
'created_at' => $now,
'updated_at' => $now,
],
);
}
$after = [
'id' => $id,
'txn_date' => $date,
'account_short' => $accountShort,
'counterparty_info' => $values['counterparty_info'],
'detail' => $values['detail'],
'income' => $income,
'expense' => $expense,
'balance' => $values['balance'],
'approval_no' => $values['approval_no'],
'payment_remark' => $values['payment_remark'],
'source' => $values['source'],
'custom_values' => $customAfter,
];
$this->audit(
'stmt_txn_update',
(string) $before->row_key,
[
'before' => $this->rowSnapshot($before, $customBefore),
'after' => $after,
],
);
return $after;
});
}
/**
* @return array<string,mixed>
*/
public function delete(int $id): array
{
return $this->connection->transaction(function () use ($id): array {
$before = $this->activeRow($id, true);
$customFields = $this->customFields((string) $before->account_short);
$customValues = $this->customValues($id, $customFields);
$now = now();
$this->connection->table('fin_transactions')->where('id', $id)->update([
'deleted_at' => $now,
'updated_by' => (string) (user()?->name ?? ''),
'updated_at' => $now,
]);
$this->audit(
'stmt_txn_delete',
(string) $before->row_key,
[
'before' => $this->rowSnapshot($before, $customValues),
'after' => null,
],
);
return $this->rowSnapshot($before, $customValues);
});
}
/**
* @return list<array{id:int,display_order:float}>
*/
public function insertAround(int $id, string $position, int $count): array
{
if (! in_array($position, ['above', 'below'], true) || $count < 1 || $count > 20) {
throw ValidationException::withMessages([
'position' => '请选择在上方或下方插入 1 至 20 行。',
]);
}
return $this->connection->transaction(function () use ($id, $position, $count): array {
$anchor = $this->activeRow($id, true);
$rows = $this->connection->table('fin_transactions')
->where('account_short', (string) $anchor->account_short)
->whereDate('txn_date', (string) $anchor->txn_date)
->whereNull('deleted_at')
->orderByRaw('COALESCE(display_order, id) DESC')
->orderByDesc('id')
->lockForUpdate()
->get(['id', 'display_order']);
$anchorIndex = $rows->search(
static fn (object $row): bool => (int) $row->id === $id,
);
if ($anchorIndex === false) {
throw new NotFoundHttpException('未找到要插入位置的独立账单明细。');
}
$orders = $this->insertOrders($rows->values()->all(), (int) $anchorIndex, $position, $count);
$now = now();
$created = [];
foreach ($orders as $displayOrder) {
$rowKey = hash('sha256', 'independent-insert:' . (string) Str::uuid());
$insertedId = (int) $this->connection->table('fin_transactions')->insertGetId([
'txn_date' => substr((string) $anchor->txn_date, 0, 10),
'account_short' => (string) $anchor->account_short,
'counterparty_info' => null,
'detail' => null,
'income' => 0,
'expense' => 0,
'balance' => null,
'approval_no' => null,
'payment_remark' => null,
'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE,
'source_value' => null,
'process_type' => '手工插入',
'row_key' => $rowKey,
'display_order' => $displayOrder,
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'created_by' => (string) (user()?->name ?? ''),
'updated_by' => (string) (user()?->name ?? ''),
]);
$created[] = [
'id' => $insertedId,
'display_order' => $displayOrder,
];
}
$this->audit(
'stmt_txn_insert',
(string) $anchor->row_key,
[
'anchor_id' => $id,
'position' => $position,
'count' => $count,
'after' => $created,
],
);
return $created;
});
}
/**
* @param list<object> $rows
* @return list<float>
*/
private function insertOrders(array $rows, int $anchorIndex, string $position, int $count): array
{
$anchorOrder = $this->displayOrder($rows[$anchorIndex]);
$orders = [];
if ($position === 'above') {
$upperOrder = $anchorIndex > 0 ? $this->displayOrder($rows[$anchorIndex - 1]) : null;
for ($index = 1; $index <= $count; $index++) {
$orders[] = $upperOrder === null || $upperOrder <= $anchorOrder
? $anchorOrder + $index
: round($anchorOrder + (($upperOrder - $anchorOrder) * $index / ($count + 1)), 10);
}
return $orders;
}
$lowerOrder = $anchorIndex + 1 < count($rows)
? $this->displayOrder($rows[$anchorIndex + 1])
: null;
for ($index = 1; $index <= $count; $index++) {
$orders[] = $lowerOrder === null || $lowerOrder >= $anchorOrder
? $anchorOrder - $index
: round($lowerOrder + (($anchorOrder - $lowerOrder) * $index / ($count + 1)), 10);
}
return $orders;
}
private function displayOrder(object $row): float
{
return $row->display_order === null ? (float) $row->id : (float) $row->display_order;
}
private function activeRow(int $id, bool $lock = false): object
{
$query = $this->connection->table('fin_transactions')
->where('id', $id)
->whereIn('account_short', FinanceLedgerIndependentAccounts::names())
->whereNull('deleted_at');
if ($lock) {
$query->lockForUpdate();
}
$row = $query->first();
if ($row === null) {
throw new NotFoundHttpException('未找到可编辑的独立账单明细。');
}
return $row;
}
/**
* @return array<string,array{id:int,field_key:string,field_name:string}>
*/
private function customFields(string $accountShort): array
{
return $this->connection->table('fin_transaction_custom_fields')
->where('account_short', $accountShort)
->orderBy('sort_order')
->orderBy('id')
->get(['id', 'field_key', 'field_name'])
->mapWithKeys(static fn (object $field): array => [
(string) $field->field_key => [
'id' => (int) $field->id,
'field_key' => (string) $field->field_key,
'field_name' => (string) $field->field_name,
],
])
->all();
}
/**
* @param array<string,array{id:int,field_key:string,field_name:string}> $fields
* @return array<string,?string>
*/
private function customValues(int $transactionId, array $fields): array
{
if ($fields === []) {
return [];
}
$values = $this->connection->table('fin_transaction_custom_values')
->where('transaction_id', $transactionId)
->whereIn('custom_field_id', array_column($fields, 'id'))
->pluck('value', 'custom_field_id');
$result = [];
foreach ($fields as $fieldKey => $field) {
$value = $values->get((int) $field['id']);
$result[$fieldKey] = $value === null ? null : (string) $value;
}
return $result;
}
/**
* @param array<string,array{id:int,field_key:string,field_name:string}> $fields
* @param array<string,?string> $existing
* @return array<string,?string>
*/
private function submittedCustomValues(mixed $submitted, array $fields, array $existing): array
{
if ($submitted === null) {
return $existing;
}
if (! is_array($submitted)) {
throw ValidationException::withMessages([
'custom_values' => '自定义列数据格式不正确。',
]);
}
$unknown = array_diff(array_keys($submitted), array_keys($fields));
if ($unknown !== []) {
throw ValidationException::withMessages([
'custom_values' => '自定义列已变更,请刷新页面后重新编辑。',
]);
}
$values = [];
foreach ($fields as $fieldKey => $field) {
$values[$fieldKey] = $this->nullableText($submitted[$fieldKey] ?? null, 4000);
}
return $values;
}
/**
* @return array<string,mixed>
*/
private function rowSnapshot(object $row, array $customValues): array
{
return [
'id' => (int) $row->id,
'row_key' => (string) $row->row_key,
'txn_date' => substr((string) $row->txn_date, 0, 10),
'account_short' => (string) $row->account_short,
'counterparty_info' => $row->counterparty_info !== null ? (string) $row->counterparty_info : null,
'detail' => $row->detail !== null ? (string) $row->detail : null,
'income' => round((float) $row->income, 2),
'expense' => round((float) $row->expense, 2),
'balance' => $row->balance !== null ? round((float) $row->balance, 2) : null,
'approval_no' => $row->approval_no !== null ? (string) $row->approval_no : null,
'payment_remark' => $row->payment_remark !== null ? (string) $row->payment_remark : null,
'source' => (string) $row->source,
'custom_values' => $customValues,
];
}
/**
* @param array<string,mixed> $changes
*/
private function audit(string $entityType, string $rowKey, array $changes): void
{
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => $entityType,
'entity_key' => 'independent_transaction|' . $rowKey,
'changes' => json_encode($changes, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? ''),
'created_at' => now(),
]);
}
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([
'txn_date' => '动账日期必须使用 YYYY-MM-DD 格式。',
]);
}
return $text;
}
private function cents(mixed $value): int
{
return (int) round((float) $value * 100);
}
private function nullableMoney(mixed $value): ?float
{
$text = trim((string) ($value ?? ''));
return $text === '' ? null : round((float) $text, 2);
}
private function nullableText(mixed $value, int $length): ?string
{
$text = trim((string) ($value ?? ''));
if ($text === '') {
return null;
}
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
if (mb_strlen($text) > $length) {
throw ValidationException::withMessages([
'detail' => '字段内容超过允许长度。',
]);
}
return $text;
}
}