429 lines
16 KiB
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;
|
|
}
|
|
}
|