download(); } public function unavailableBalanceTasks(Request $request) { $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $abnormalStatuses = array_values(array_diff( array_keys(FinanceLedgerAccountStatus::OPTIONS), [FinanceLedgerAccountStatus::NORMAL], )); $handlingMethods = [ 'online' => '线上', 'offline' => '线下', ]; $completionStatuses = [ 'completed' => '已完成', 'in_progress' => '进行中', 'not_started' => '待开始', 'incomplete' => '未完成', ]; $filters = [ 'pending_short' => trim((string) $request->query('pending_short', '')), 'pending_type' => trim((string) $request->query('pending_type', '')), 'pending_status' => trim((string) $request->query('pending_status', '')), 'pending_reason' => trim((string) $request->query('pending_reason', '')), 'pending_method' => trim((string) $request->query('pending_method', '')), 'pending_owner' => trim((string) $request->query('pending_owner', '')), ]; $query = $connection->table('fin_accounts as accounts') ->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short') ->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short') ->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short') ->whereIn('account_status.status', $abnormalStatuses); if ($filters['pending_short'] !== '') { $query->where('accounts.account_short', 'like', '%' . $filters['pending_short'] . '%'); } if ($filters['pending_type'] !== '') { $query->where(function ($effectiveType) use ($filters): void { $effectiveType->where(function ($baseType) use ($filters): void { $baseType->whereNull('type_override.id'); if ($filters['pending_type'] === '__empty__') { $baseType->where(function ($blank): void { $blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', ''); }); } else { $baseType->where('accounts.account_type', $filters['pending_type']); } })->orWhere(function ($overrideType) use ($filters): void { $overrideType->whereNotNull('type_override.id'); if ($filters['pending_type'] === '__empty__') { $overrideType->where(function ($blank): void { $blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', ''); }); } else { $overrideType->where('type_override.account_type', $filters['pending_type']); } }); }); } if ($filters['pending_status'] !== '' && array_key_exists($filters['pending_status'], FinanceLedgerAccountStatus::OPTIONS)) { $query->where('account_status.status', $filters['pending_status']); } if ($filters['pending_reason'] !== '') { $query->where('account_todo.abnormal_reason', 'like', '%' . $filters['pending_reason'] . '%'); } if ($filters['pending_method'] !== '' && array_key_exists($filters['pending_method'], $handlingMethods)) { $query->where('account_todo.handling_method', $filters['pending_method']); } if ($filters['pending_owner'] !== '') { $query->where('account_todo.owner_name', 'like', '%' . $filters['pending_owner'] . '%'); } $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), 'current_balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2), 'balance' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2), 'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2), 'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2), ]; $sortField = (string) $request->query('pending_sort', ''); $sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : ''; $sortDirection = strtolower((string) $request->query('pending_direction', 'asc')); $sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc'; $query->select([ 'accounts.*', 'account_status.status as effective_account_status', 'type_override.id as type_override_id', 'type_override.account_type as effective_account_type', 'account_todo.abnormal_reason as todo_abnormal_reason', 'account_todo.handling_method as todo_handling_method', 'account_todo.owner_name as todo_owner_name', 'account_todo.started_at as todo_started_at', 'account_todo.completed_at as todo_completed_at', 'account_todo.current_progress as todo_current_progress', 'account_todo.completion_status as todo_completion_status', ]); if ($sortField !== '') { $query->orderBy('account_todo.' . $sortField, $sortDirection); } else { $query->orderByDesc('accounts.blocked_amount'); } $rows = $query ->orderBy('accounts.company_name') ->orderBy('accounts.account_short') ->paginate(50) ->appends($filters) ->appends(array_filter([ 'pending_sort' => $sortField, 'pending_direction' => $sortField !== '' ? $sortDirection : '', ])) ->withQueryString(); $rows->getCollection()->transform(static function ($row) { $row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null); if ($row->type_override_id !== null) { $row->account_type = (string) ($row->effective_account_type ?? ''); } $row->todo_completion_status = $row->todo_completion_status ?: 'not_started'; return $row; }); $typeOptions = FinanceLedgerAccountType::options( $connection->table('fin_accounts') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(), $connection->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(), ); $reasonOptions = $connection->table('fin_account_todos') ->whereNotNull('abnormal_reason')->where('abnormal_reason', '<>', '') ->distinct()->orderBy('abnormal_reason')->pluck('abnormal_reason')->all(); $ownerOptions = $connection->table('fin_account_todos') ->whereNotNull('owner_name')->where('owner_name', '<>', '') ->distinct()->orderBy('owner_name')->pluck('owner_name')->all(); return view('business.pages.financial-ledger.unavailable-balance-tasks', [ 'rows' => $rows, 'summary' => $summary, 'filters' => $filters, 'sortField' => $sortField, 'sortDirection' => $sortDirection, 'backUrl' => route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'ledger', ]) . '?tab=accounts', 'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()), 'handlingMethods' => $handlingMethods, 'completionStatuses' => $completionStatuses, 'accountStatusOptions' => array_intersect_key( FinanceLedgerAccountStatus::OPTIONS, array_flip($abnormalStatuses), ), 'accountTypeOptions' => $typeOptions, 'reasonOptions' => $reasonOptions, 'ownerOptions' => $ownerOptions, ]); } public function unretainedAccountTasks(Request $request) { $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $handlingMethods = [ 'online' => '线上', 'offline' => '线下', ]; $completionStatuses = [ 'completed' => '已完成', 'in_progress' => '进行中', 'not_started' => '待开始', ]; $filters = [ 'keep_short' => trim((string) $request->query('keep_short', '')), 'keep_type' => trim((string) $request->query('keep_type', '')), 'keep_status' => trim((string) $request->query('keep_status', '')), 'keep_method' => trim((string) $request->query('keep_method', '')), 'keep_owner' => trim((string) $request->query('keep_owner', '')), 'keep_progress' => trim((string) $request->query('keep_progress', '')), ]; $query = $connection->table('fin_accounts as accounts') ->leftJoin('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short') ->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short') ->leftJoin('fin_account_keep_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short') ->select([ 'accounts.*', 'account_status.status as effective_account_status', 'type_override.id as type_override_id', 'type_override.account_type as effective_account_type', 'account_todo.handling_method as todo_handling_method', 'account_todo.owner_name as todo_owner_name', 'account_todo.started_at as todo_started_at', 'account_todo.completed_at as todo_completed_at', 'account_todo.current_progress as todo_current_progress', ]); FinanceLedgerAccountShouldKeep::applyUnretainedCondition( $query, 'accounts.account_should_keep', 'accounts.company_plan', ); if ($filters['keep_short'] !== '') { $query->where('accounts.account_short', 'like', '%' . $filters['keep_short'] . '%'); } if ($filters['keep_type'] !== '') { $query->where(function ($effectiveType) use ($filters): void { $effectiveType->where(function ($baseType) use ($filters): void { $baseType->whereNull('type_override.id'); if ($filters['keep_type'] === '__empty__') { $baseType->where(function ($blank): void { $blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', ''); }); } else { $baseType->where('accounts.account_type', $filters['keep_type']); } })->orWhere(function ($overrideType) use ($filters): void { $overrideType->whereNotNull('type_override.id'); if ($filters['keep_type'] === '__empty__') { $overrideType->where(function ($blank): void { $blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', ''); }); } else { $overrideType->where('type_override.account_type', $filters['keep_type']); } }); }); } if ($filters['keep_status'] !== '' && array_key_exists($filters['keep_status'], FinanceLedgerAccountStatus::OPTIONS)) { if ($filters['keep_status'] === FinanceLedgerAccountStatus::NORMAL) { $query->where(function ($normalStatus): void { $normalStatus->whereNull('account_status.status') ->orWhere('account_status.status', FinanceLedgerAccountStatus::NORMAL); }); } else { $query->where('account_status.status', $filters['keep_status']); } } if ($filters['keep_method'] !== '' && array_key_exists($filters['keep_method'], $handlingMethods)) { $query->where('account_todo.handling_method', $filters['keep_method']); } if ($filters['keep_owner'] !== '') { $query->where('account_todo.owner_name', 'like', '%' . $filters['keep_owner'] . '%'); } if ($filters['keep_progress'] !== '' && array_key_exists($filters['keep_progress'], $completionStatuses)) { if ($filters['keep_progress'] === 'not_started') { $query->where(function ($notStarted): void { $notStarted->whereNull('account_todo.current_progress') ->orWhere('account_todo.current_progress', 'not_started'); }); } else { $query->where('account_todo.current_progress', $filters['keep_progress']); } } $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), 'in_progress' => (clone $summaryQuery)->where('account_todo.current_progress', 'in_progress')->count(), 'not_started' => (clone $summaryQuery)->where(function ($notStarted): void { $notStarted->whereNull('account_todo.current_progress') ->orWhere('account_todo.current_progress', 'not_started'); })->count(), 'completed' => (clone $summaryQuery)->where('account_todo.current_progress', 'completed')->count(), ]; $sortField = (string) $request->query('keep_sort', ''); $sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : ''; $sortDirection = strtolower((string) $request->query('keep_direction', 'asc')); $sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc'; if ($sortField !== '') { $query->orderBy('account_todo.' . $sortField, $sortDirection); } $rows = $query ->orderBy('accounts.company_name') ->orderBy('accounts.account_short') ->paginate(50) ->appends($filters) ->appends(array_filter([ 'keep_sort' => $sortField, 'keep_direction' => $sortField !== '' ? $sortDirection : '', ])) ->withQueryString(); $rows->getCollection()->transform(static function ($row) { $row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null); if ($row->type_override_id !== null) { $row->account_type = (string) ($row->effective_account_type ?? ''); } $row->todo_current_progress = $row->todo_current_progress ?: 'not_started'; return $row; }); $typeOptions = FinanceLedgerAccountType::options( $connection->table('fin_accounts') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(), $connection->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(), ); $ownerOptions = $connection->table('fin_account_keep_todos') ->whereNotNull('owner_name')->where('owner_name', '<>', '') ->distinct()->orderBy('owner_name')->pluck('owner_name')->all(); return view('business.pages.financial-ledger.unretained-account-tasks', [ 'rows' => $rows, 'summary' => $summary, 'filters' => $filters, 'sortField' => $sortField, 'sortDirection' => $sortDirection, 'backUrl' => route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'ledger', ]) . '?tab=accounts', 'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()), 'handlingMethods' => $handlingMethods, 'completionStatuses' => $completionStatuses, 'accountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'accountTypeOptions' => $typeOptions, 'ownerOptions' => $ownerOptions, ]); } public function updateAccountKeepTodo(Request $request) { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改不保留账户待办。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], 'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])], 'owner_name' => ['nullable', 'string', 'max:100'], 'started_at' => ['nullable', 'date'], 'completed_at' => ['nullable', 'date'], 'current_progress' => ['required', 'string', Rule::in(['completed', 'in_progress', 'not_started'])], ]); $accountShort = trim((string) $data['account_short']); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $account = $connection->table('fin_accounts') ->where('account_short', $accountShort) ->first(['account_should_keep', 'company_plan']); abort_if($account === null, 422, '账户简称不存在,请刷新待办清单后重试。'); abort_unless( FinanceLedgerAccountShouldKeep::display( $account->account_should_keep ?? null, $account->company_plan ?? null, ) === FinanceLedgerAccountShouldKeep::NO, 422, '账户当前已设置为保留,无需继续填写不保留账户待办。', ); $now = now(); $values = [ 'handling_method' => $data['handling_method'] ?? null, 'owner_name' => $data['owner_name'] ?? null, 'started_at' => filled($data['started_at'] ?? null) ? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s') : null, 'completed_at' => filled($data['completed_at'] ?? null) ? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s') : null, 'current_progress' => (string) $data['current_progress'], 'updated_by' => (string) user()->name, 'updated_at' => $now, ]; $todos = $connection->table('fin_account_keep_todos'); $existing = $todos->where('account_short', $accountShort)->exists(); if ($existing) { $todos->where('account_short', $accountShort)->update($values); } else { $todos->insert($values + [ 'account_short' => $accountShort, 'created_at' => $now, ]); } return redirect()->back()->with('success', '不保留账户待办已保存'); } public function updateAccountTodo(Request $request, FinanceLedgerCalculator $calculator) { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], 'abnormal_reason' => ['nullable', 'string', 'max:1000'], 'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])], 'owner_name' => ['nullable', 'string', 'max:100'], 'started_at' => ['nullable', 'date'], 'completed_at' => ['nullable', 'date'], 'current_progress' => ['nullable', 'string', 'max:100'], 'completion_status' => ['nullable', 'string', Rule::in(['completed', 'in_progress', 'not_started', 'incomplete'])], ]); $accountShort = trim((string) $data['account_short']); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); abort_unless( $connection->table('fin_accounts')->where('account_short', $accountShort)->exists(), 422, '账户简称不存在,请刷新待办清单后重试。', ); $abnormalStatuses = array_values(array_diff( array_keys(FinanceLedgerAccountStatus::OPTIONS), [FinanceLedgerAccountStatus::NORMAL], )); $status = $connection->table('fin_account_statuses') ->where('account_short', $accountShort) ->value('status'); abort_unless( $status !== null && in_array(FinanceLedgerAccountStatus::normalize((string) $status), $abnormalStatuses, true), 422, '账户当前已不是异常状态,无需继续填写待办。', ); $now = now(); $completionStatus = (string) ($data['completion_status'] ?? 'not_started'); $values = [ 'abnormal_reason' => $data['abnormal_reason'] ?? null, 'handling_method' => $data['handling_method'] ?? null, 'owner_name' => $data['owner_name'] ?? null, 'started_at' => filled($data['started_at'] ?? null) ? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s') : null, 'completed_at' => filled($data['completed_at'] ?? null) ? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s') : null, 'current_progress' => $data['current_progress'] ?? null, 'completion_status' => $completionStatus, 'updated_by' => (string) user()->name, 'updated_at' => $now, ]; $connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus, $calculator): void { $todos = $connection->table('fin_account_todos'); $existing = $todos->where('account_short', $accountShort)->exists(); if ($existing) { $todos->where('account_short', $accountShort)->update($values); } else { $todos->insert($values + [ 'account_short' => $accountShort, 'created_at' => $now, ]); } if ($completionStatus === 'completed') { $connection->table('fin_account_statuses') ->where('account_short', $accountShort) ->update([ 'status' => FinanceLedgerAccountStatus::NORMAL, 'updated_by' => (string) user()->name, 'updated_at' => $now, ]); $calculator->refreshAccountAmounts($accountShort); } }); return redirect()->back()->with( 'success', $completionStatus === 'completed' ? '待办已完成,账户状态已自动设为正常' : '待办事项已保存', ); } public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。'); $data = $request->validate([ 'account_short' => ['nullable', 'string', 'max:100'], ]); $account = trim((string) ($data['account_short'] ?? '')); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $row = $connection->table('fin_account_map')->where('id', $id)->first(); abort_if($row === null, 404); if ($account !== '') { $exists = $connection->table('fin_accounts')->where('account_short', $account)->exists(); abort_unless($exists, 422, '账户简称不存在,请从账户总表中选择。'); } $old = (string) ($row->account_short ?? ''); $connection->table('fin_account_map')->where('id', $id)->update([ 'account_short' => $account !== '' ? $account : null, 'updated_by' => (string) user()->name, 'updated_at' => now(), ]); $connection->table('fin_account_map_events')->insert([ 'source' => (string) $row->source, 'source_value' => (string) $row->source_value, 'old_account_short' => $old !== '' ? $old : null, 'new_account_short' => $account !== '' ? $account : null, 'user_id' => (int) user()->id, 'user_name' => (string) user()->name, 'created_at' => now(), ]); if ($request->boolean('recalculate', true)) { $calculator->recalculate(); } $mapped = $connection->table('fin_account_map') ->whereNotNull('account_short')->where('account_short', '<>', '')->count(); return response()->json([ 'ok' => true, 'account_short' => $account, 'mapped' => $mapped, 'total' => $connection->table('fin_account_map')->count(), ], 200, ['Cache-Control' => 'private, no-store']); } public function storeManualTransaction( Request $request, FinanceLedgerCalculator $calculator, FinanceLedgerPageData $pageData, ): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以新增手工动账明细。'); $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); $data = $request->validate([ 'idempotency_key' => ['required', 'uuid'], 'txn_date' => ['required', 'date', 'after_or_equal:' . $openingDate], 'account_short' => ['required', 'string', 'max:100'], 'detail' => ['required', 'string', 'max:4000'], 'income' => ['nullable', 'numeric', 'min:0', 'max:9999999999999999.99'], 'expense' => ['nullable', 'numeric', 'min:0', 'max:9999999999999999.99'], 'approval_no' => ['nullable', 'string', 'max:100'], ]); $accountShort = trim((string) $data['account_short']); $income = round((float) ($data['income'] ?? 0), 2); $expense = round((float) ($data['expense'] ?? 0), 2); if (($income > 0) === ($expense > 0)) { throw ValidationException::withMessages([ 'income' => '收入和支出只能填写一项。', ]); } $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $account = $connection->table('fin_accounts') ->where('account_short', $accountShort) ->orderBy('id') ->first(['account_short', 'company_name']); abort_if($account === null, 422, '账户简称不存在,请从账户总表中选择。'); $idempotencyKey = (string) $data['idempotency_key']; $rowKey = hash('sha256', FinanceLedgerTransactionWriter::MANUAL_SOURCE . ':' . $idempotencyKey); $detail = preg_replace('/\s+/u', ' ', trim((string) $data['detail'])) ?? trim((string) $data['detail']); $approvalNo = trim((string) ($data['approval_no'] ?? '')); if ($approvalNo === '') { $approvalNo = 'MAN-' . str_replace('-', '', (string) $data['txn_date']) . '-' . strtoupper(substr($rowKey, 0, 6)); } $inserted = $connection->transaction(function () use ( $connection, $calculator, $rowKey, $data, $accountShort, $detail, $income, $expense, $approvalNo, ): bool { $now = now(); $created = $connection->table('fin_transactions')->insertOrIgnore([ 'txn_date' => $data['txn_date'], 'account_short' => $accountShort, 'detail' => $detail, 'income' => $income, 'expense' => $expense, 'approval_no' => mb_substr($approvalNo, 0, 100), 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, 'source_value' => null, 'process_type' => '手工录入', 'row_key' => $rowKey, 'synced_at' => $now, 'created_at' => $now, 'updated_at' => $now, 'created_by' => (string) user()->name, 'updated_by' => (string) user()->name, ]) === 1; if ($created) { $calculator->recalculate(); } return $created; }); $row = $connection->table('fin_transactions')->where('row_key', $rowKey)->first(); abort_if($row === null, 500, '手工动账明细保存失败,请刷新后重试。'); $accountInfo = $connection->table('fin_accounts') ->where('account_short', $accountShort) ->orderBy('id') ->first(['account_short', 'company_name', 'current_balance']); $flow = $connection->table('fin_daily_flows') ->where('account_short', $accountShort) ->whereDate('flow_date', (string) $row->txn_date) ->first(['flow_date', 'opening_balance', 'inflow', 'outflow', 'closing_balance']); return response()->json([ 'ok' => true, 'created' => $inserted, 'row' => [ 'id' => (int) $row->id, 'txn_date' => (string) $row->txn_date, 'account_short' => (string) $row->account_short, 'company_name' => (string) ($accountInfo->company_name ?? ''), 'detail' => (string) $row->detail, 'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE, 'source_label' => '手工录入', 'income' => round((float) $row->income, 2), 'expense' => round((float) $row->expense, 2), 'approval_no' => (string) ($row->approval_no ?? ''), ], 'account' => [ 'account_short' => (string) ($accountInfo->account_short ?? $accountShort), 'company_name' => (string) ($accountInfo->company_name ?? ''), 'current_balance' => round((float) ($accountInfo->current_balance ?? 0), 2), ], 'daily_flow' => $flow === null ? null : [ 'flow_date' => (string) $flow->flow_date, 'opening_balance' => round((float) $flow->opening_balance, 2), 'inflow' => round((float) $flow->inflow, 2), 'outflow' => round((float) $flow->outflow, 2), 'closing_balance' => round((float) $flow->closing_balance, 2), ], 'summary' => $pageData->transactionSummary(), 'total_transactions' => $connection->table('fin_transactions')->count(), 'next_idempotency_key' => (string) Str::uuid(), ], $inserted ? 201 : 200, ['Cache-Control' => 'private, no-store']); } public function updateTransactionAccount(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改动账账户关联。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], ]); $accountShort = trim((string) $data['account_short']); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $account = $connection->table('fin_accounts') ->where('account_short', $accountShort) ->orderBy('id') ->first(['account_short', 'company_name']); abort_if($account === null, 422, '账户简称不存在,请从账户总表中选择。'); $result = $connection->transaction(function () use ( $connection, $calculator, $id, $accountShort, ): array { $transaction = $connection->table('fin_transactions') ->where('id', $id) ->lockForUpdate() ->first(); abort_if($transaction === null, 404); $source = trim((string) $transaction->source); $sourceValue = trim((string) ($transaction->source_value ?? '')); $directTransaction = $source === FinanceLedgerTransactionWriter::MANUAL_SOURCE || $sourceValue === ''; $groupKey = $directTransaction ? 'transaction:' . $id : hash('sha256', $source . '|' . $sourceValue); $mapping = null; $old = trim((string) ($transaction->account_short ?? '')); if (! $directTransaction) { $mapping = $connection->table('fin_account_map') ->where('source', $source) ->where('source_value', $sourceValue) ->lockForUpdate() ->first(); $mappedAccount = trim((string) ($mapping->account_short ?? '')); if ($mappedAccount !== '') { $old = $mappedAccount; } } if ($old === $accountShort) { return [ 'changed' => false, 'scope' => $directTransaction ? 'transaction' : 'mapping', 'group_key' => $groupKey, 'txn_date' => (string) $transaction->txn_date, ]; } $now = now(); if ($directTransaction) { $connection->table('fin_transactions')->where('id', $id)->update([ 'account_short' => $accountShort, 'updated_by' => (string) user()->name, 'updated_at' => $now, ]); } else { if ($mapping === null) { $stats = $connection->table('fin_transactions') ->where('source', $source) ->where('source_value', $sourceValue) ->selectRaw('COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum') ->first(); $connection->table('fin_account_map')->insert([ 'source' => $source, 'source_value' => $sourceValue, 'account_short' => $accountShort, 'txn_count' => (int) ($stats->txn_count ?? 0), 'income_sum' => round((float) ($stats->income_sum ?? 0), 2), 'expense_sum' => round((float) ($stats->expense_sum ?? 0), 2), 'updated_by' => (string) user()->name, 'created_at' => $now, 'updated_at' => $now, ]); } else { $connection->table('fin_account_map')->where('id', $mapping->id)->update([ 'account_short' => $accountShort, 'updated_by' => (string) user()->name, 'updated_at' => $now, ]); } $connection->table('fin_account_map_events')->insert([ 'source' => $source, 'source_value' => $sourceValue, 'old_account_short' => $old !== '' ? $old : null, 'new_account_short' => $accountShort, 'user_id' => (int) user()->id, 'user_name' => (string) user()->name, 'created_at' => $now, ]); } $calculator->recalculate(); return [ 'changed' => true, 'scope' => $directTransaction ? 'transaction' : 'mapping', 'group_key' => $groupKey, 'txn_date' => (string) $transaction->txn_date, ]; }); $flow = $connection->table('fin_daily_flows') ->where('account_short', $accountShort) ->whereDate('flow_date', (string) $result['txn_date']) ->first(['flow_date', 'opening_balance', 'inflow', 'outflow', 'closing_balance']); $currentBalance = $connection->table('fin_accounts') ->where('account_short', $accountShort) ->orderBy('id') ->value('current_balance'); return response()->json([ 'ok' => true, 'changed' => (bool) $result['changed'], 'scope' => (string) $result['scope'], 'group_key' => (string) $result['group_key'], 'account_short' => $accountShort, 'company_name' => (string) ($account->company_name ?? ''), 'current_balance' => round((float) ($currentBalance ?? 0), 2), 'daily_flow' => $flow === null ? null : [ 'flow_date' => (string) $flow->flow_date, 'opening_balance' => round((float) $flow->opening_balance, 2), 'inflow' => round((float) $flow->inflow, 2), 'outflow' => round((float) $flow->outflow, 2), 'closing_balance' => round((float) $flow->closing_balance, 2), ], ], 200, ['Cache-Control' => 'private, no-store']); } public function updateAccountStatus(Request $request, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], 'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))], ]); $accountShort = trim((string) $data['account_short']); abort_if($accountShort === '', 422, '账户简称不能为空。'); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); abort_unless( $connection->table('fin_accounts')->where('account_short', $accountShort)->exists(), 422, '账户简称不存在,请刷新账户总表后重试。', ); $status = FinanceLedgerAccountStatus::normalize((string) $data['status']); $statuses = $connection->table('fin_account_statuses'); $existing = $statuses->where('account_short', $accountShort)->first(); $now = now(); $values = [ 'status' => $status, 'updated_by' => (string) user()->name, 'updated_at' => $now, ]; $connection->transaction(function () use ($statuses, $existing, $values, $accountShort, $now, $calculator): void { if ($existing === null) { $statuses->insert($values + [ 'account_short' => $accountShort, 'created_at' => $now, ]); } else { $statuses->where('account_short', $accountShort)->update($values); } $calculator->refreshAccountAmounts($accountShort); }); return response()->json([ 'ok' => true, 'account_short' => $accountShort, 'status' => $status, 'label' => FinanceLedgerAccountStatus::label($status), ], 200, ['Cache-Control' => 'private, no-store']); } public function updateAccountType(Request $request): JsonResponse { abort_unless(FinanceLedgerAccess::canEditAccountType(user()), 403, '只有财务台账管理员可以修改账户类型。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], 'account_type' => ['nullable', 'string', 'max:50'], ]); $accountShort = trim((string) $data['account_short']); $accountType = trim((string) ($data['account_type'] ?? '')); abort_if($accountShort === '', 422, '账户简称不能为空。'); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $account = $connection->table('fin_accounts') ->where('account_short', $accountShort) ->first(['account_no', 'account_last4']); abort_if($account === null, 422, '账户简称不存在,请刷新账户总表后重试。'); if ($accountType !== '') { $allowedTypes = FinanceLedgerAccountType::options( $connection->table('fin_accounts') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->pluck('account_type')->all(), $connection->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->pluck('account_type')->all(), ); abort_unless(in_array($accountType, $allowedTypes, true), 422, '请选择现有账户类型。'); } $overrides = $connection->table('fin_account_type_overrides'); $existing = $overrides->where('account_short', $accountShort)->first(); $now = now(); $values = [ 'account_type' => $accountType !== '' ? $accountType : null, 'updated_by' => (string) user()->name, 'updated_at' => $now, ]; $accountLast4 = FinanceLedgerAccountType::last4( $accountType, (string) ($account->account_no ?? ''), (string) ($account->account_last4 ?? ''), ); $connection->transaction(function () use ($connection, $overrides, $existing, $values, $accountShort, $now, $accountLast4): void { if ($existing === null) { $overrides->insert($values + [ 'account_short' => $accountShort, 'created_at' => $now, ]); } else { $overrides->where('account_short', $accountShort)->update($values); } $connection->table('fin_accounts') ->where('account_short', $accountShort) ->update([ 'account_last4' => $accountLast4, 'updated_at' => $now, ]); }); return response()->json([ 'ok' => true, 'account_short' => $accountShort, 'account_type' => $accountType, 'label' => $accountType !== '' ? $accountType : '未填写', 'account_last4' => $accountLast4, ], 200, ['Cache-Control' => 'private, no-store']); } public function updateAccountPlan(Request $request): JsonResponse { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改公司规划。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], 'company_plan' => ['nullable', 'string', 'max:50'], ]); $accountShort = trim((string) $data['account_short']); $plan = trim((string) ($data['company_plan'] ?? '')); if ($accountShort === '') { abort(422, '账户简称不能为空。'); } $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); abort_unless( $connection->table('fin_accounts')->where('account_short', $accountShort)->exists(), 422, '账户简称不存在,请刷新账户总表后重试。', ); $allowedPlans = $connection->table('fin_accounts') ->whereNotNull('company_plan') ->where('company_plan', '<>', '') ->distinct() ->pluck('company_plan') ->map(static fn (mixed $value): string => trim((string) $value)) ->filter() ->values() ->all(); abort_unless($plan === '' || in_array($plan, $allowedPlans, true), 422, '请选择现有公司规划。'); $connection->table('fin_accounts')->where('account_short', $accountShort)->update([ 'company_plan' => $plan !== '' ? $plan : null, 'updated_at' => now(), ]); return response()->json([ 'ok' => true, 'account_short' => $accountShort, 'company_plan' => $plan, 'label' => $plan !== '' ? $plan : '未填写', ], 200, ['Cache-Control' => 'private, no-store']); } public function updateAccountKeep(Request $request): JsonResponse { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户是否保留。'); $data = $request->validate([ 'account_short' => ['required', 'string', 'max:100'], 'account_should_keep' => ['nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS)], ]); $accountShort = trim((string) $data['account_short']); $keep = FinanceLedgerAccountShouldKeep::normalize((string) ($data['account_should_keep'] ?? '')); if ($accountShort === '') { abort(422, '账户简称不能为空。'); } $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); abort_unless( $connection->table('fin_accounts')->where('account_short', $accountShort)->exists(), 422, '账户简称不存在,请刷新账户总表后重试。', ); $connection->table('fin_accounts')->where('account_short', $accountShort)->update([ 'account_should_keep' => $keep !== '' ? $keep : null, 'updated_at' => now(), ]); return response()->json([ 'ok' => true, 'account_short' => $accountShort, 'account_should_keep' => $keep, 'label' => $keep !== '' ? $keep : '未填写', ], 200, ['Cache-Control' => 'private, no-store']); } public function previewShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); $preview = $bulk->preview(); return response()->json([ 'ok' => true, 'total' => $preview['total'], 'matched' => $preview['matched'], 'unmatched' => $preview['unmatched'], 'sample' => array_slice($preview['candidates'], 0, 10), 'unmatched_sample' => array_slice($preview['unmatched_values'], 0, 10), ], 200, ['Cache-Control' => 'private, no-store']); } public function applyShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); $result = $bulk->apply(user()); return response()->json([ 'ok' => true, 'total' => $result['total'], 'mapped' => $result['mapped'], 'unmatched' => $result['unmatched'], ], 200, ['Cache-Control' => 'private, no-store']); } public function previewPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); $preview = $bulk->preview(); return response()->json([ 'ok' => true, 'total' => $preview['total'], 'eligible' => $preview['eligible'], 'ambiguous' => $preview['ambiguous'], 'missing' => $preview['missing'], 'sample' => array_slice($preview['candidates'], 0, 10), 'ambiguous_sample' => array_slice($preview['ambiguous_values'], 0, 10), 'missing_sample' => array_slice($preview['missing_values'], 0, 10), ], 200, ['Cache-Control' => 'private, no-store']); } public function applyPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); $result = $bulk->apply(user()); return response()->json([ 'ok' => true, 'total' => $result['total'], 'mapped' => $result['mapped'], 'ambiguous' => $result['ambiguous'], 'missing' => $result['missing'], ], 200, ['Cache-Control' => 'private, no-store']); } }