319 lines
11 KiB
PHP
319 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use App\Utilities\FinanceLedgerIndependentAccounts;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Database\DatabaseManager;
|
|
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);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|