connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** * @param array $data * @return array */ 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 */ 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 */ 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 $rows * @return list */ 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 */ 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 $fields * @return array */ 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 $fields * @param array $existing * @return array */ 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 */ 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 $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; } }