download(); } public function storeAccount(Request $request, FinanceLedgerAccountCreator $creator): JsonResponse { abort_unless( FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以新增公司或账户。', ); $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); $data = $request->validate([ 'company_name' => ['required', 'string', 'max:100'], 'company_register_place' => ['nullable', 'string', 'max:200'], 'company_business_type' => ['nullable', 'string', 'max:50'], 'company_tax_type' => ['nullable', 'string', 'max:50'], 'company_status' => ['nullable', 'string', 'max:20'], 'company_basic_bank' => ['nullable', 'string', 'max:200'], 'company_basic_account_last4' => ['nullable', 'string', 'max:50'], 'company_remark' => ['nullable', 'string', 'max:500'], 'account_short' => ['required', 'string', 'max:100'], 'account_type' => ['nullable', 'string', 'max:50'], 'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))], 'region' => ['nullable', 'string', 'max:50'], 'account_no' => ['nullable', 'string', 'max:50'], 'bank_name' => ['nullable', 'string', 'max:200'], 'bank_city_branch' => ['nullable', 'string', 'max:100'], 'company_plan' => ['nullable', 'string', 'max:50'], 'account_should_keep' => [ 'nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS), ], 'opening_date' => [ 'required', 'date_format:Y-m-d', 'after_or_equal:' . $openingDate, 'before_or_equal:' . now()->toDateString(), ], 'opening_balance' => ['nullable', 'numeric'], ]); $accountType = trim((string) ($data['account_type'] ?? '')); if ($accountType !== '') { $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $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(), ); if (! in_array($accountType, $allowedTypes, true)) { throw ValidationException::withMessages([ 'account_type' => '请选择现有账户类型。', ]); } } $result = $creator->create($data); $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); $message = match (true) { $result['company_created'] => '已新增公司并建立首个账户。', $result['company_updated'] => '已新增账户并更新公司资料。', default => '已新增账户并关联现有公司。', }; return response()->json([ 'ok' => true, 'message' => $message, 'account' => $result, 'total_accounts' => $connection->table('fin_accounts')->count(), 'total_companies' => $connection->table('fin_companies')->count(), ], 201, ['Cache-Control' => 'private, no-store']); } public function syncShopData(FinanceLedgerShopDataSynchronizer $synchronizer) { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以同步店铺余额和提现。'); $target = route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'ledger', ]) . '?tab=shops'; try { $result = $synchronizer->sync(now()->subDays(30)->toDateString()); } catch (Throwable $exception) { report($exception); return redirect()->to($target)->with('error', '店铺数据同步失败:' . $exception->getMessage()); } $message = sprintf( '已补充确认店铺 %s 个,同步上一晚店铺余额 %s 个,合计 %s 元;新增或更新提现 %s 笔,收入 %s 元%s。', number_format((int) $result['balance']['added']), number_format((int) $result['balance']['matched']), number_format((float) $result['balance']['amount'], 2), number_format((int) $result['withdrawals']['written']), number_format((float) $result['withdrawals']['income'], 2), $result['withdrawals']['upstream_duplicates'] > 0 ? ',跳过已有记录 ' . number_format((int) $result['withdrawals']['upstream_duplicates']) . ' 笔' : '', ); if ($result['withdrawals']['unmatched'] > 0 || $result['withdrawals']['account_missing'] > 0) { $message .= sprintf( ';店铺未匹配 %s 笔,账户缺失 %s 笔', number_format((int) $result['withdrawals']['unmatched']), number_format((int) $result['withdrawals']['account_missing']), ); } return redirect()->to($target)->with('success', $message); } 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', '')), ]; $headerFilterValue = trim((string) $request->query('keep_filter_value', '')); $headerFilterColumn = trim((string) $request->query('keep_filter_column', '')); $headerFilterColumns = [ 'company_name', 'account_short', 'legal_representative', 'account_type', 'account_status', 'account_no', 'bank_name', 'bank_city_branch', 'account_should_keep', 'handling_method', 'owner_name', 'started_at', 'completed_at', 'current_progress', ]; if ($headerFilterValue === '' || ! in_array($headerFilterColumn, $headerFilterColumns, true)) { $headerFilterColumn = ''; $headerFilterValue = ''; } $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.*', 'accounts.account_type as sort_account_type', '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']); } } $headerOptionRows = (clone $query) ->reorder() ->select([ 'accounts.company_name', 'accounts.account_short', 'accounts.account_type', 'accounts.account_no', 'accounts.bank_name', 'accounts.bank_city_branch', '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', ]) ->get(); $companyNames = $headerOptionRows ->pluck('company_name') ->map(static fn ($name): string => trim((string) $name)) ->filter() ->unique() ->values() ->all(); $legalRepresentatives = $this->latestLegalRepresentatives($connection, $companyNames); $effectiveAccountTypes = $headerOptionRows->map(static function ($row): string { $type = $row->type_override_id !== null ? $row->effective_account_type : $row->account_type; return trim((string) $type); }); $typeOptions = FinanceLedgerAccountType::options($effectiveAccountTypes->all()); if ($headerFilterValue !== '') { $textFilterColumns = [ 'company_name' => 'accounts.company_name', 'account_short' => 'accounts.account_short', 'account_no' => 'accounts.account_no', 'bank_name' => 'accounts.bank_name', 'bank_city_branch' => 'accounts.bank_city_branch', ]; if (isset($textFilterColumns[$headerFilterColumn])) { $query->where($textFilterColumns[$headerFilterColumn], 'like', '%' . $headerFilterValue . '%'); } elseif ($headerFilterColumn === 'legal_representative') { $legalFilterValue = trim((string) preg_replace('/[((]\d+[))]\s*$/u', '', $headerFilterValue)); $allCompanyNames = $headerOptionRows ->pluck('company_name') ->map(static fn ($name): string => trim((string) $name)) ->filter() ->unique() ->values(); $matchingEmptyValues = in_array($legalFilterValue, ['__empty__', '未填写'], true); $matchingCompanies = $allCompanyNames ->filter(static function ($companyName) use ($legalRepresentatives, $legalFilterValue, $matchingEmptyValues): bool { $representative = trim((string) ($legalRepresentatives[$companyName] ?? '')); return $matchingEmptyValues ? $representative === '' : str_contains($representative, $legalFilterValue); }) ->all(); if ($matchingCompanies === []) { $query->whereRaw('1 = 0'); } else { $query->whereIn('accounts.company_name', $matchingCompanies); } } elseif ($headerFilterColumn === 'account_type') { $query->where(function ($effectiveType) use ($headerFilterValue): void { $effectiveType->where(function ($baseType) use ($headerFilterValue): void { $baseType->whereNull('type_override.id'); if ($headerFilterValue === '__empty__') { $baseType->where(function ($blank): void { $blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', ''); }); } else { $baseType->where('accounts.account_type', $headerFilterValue); } })->orWhere(function ($overrideType) use ($headerFilterValue): void { $overrideType->whereNotNull('type_override.id'); if ($headerFilterValue === '__empty__') { $overrideType->where(function ($blank): void { $blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', ''); }); } else { $overrideType->where('type_override.account_type', $headerFilterValue); } }); }); } elseif ($headerFilterColumn === 'account_status') { $status = array_search($headerFilterValue, FinanceLedgerAccountStatus::OPTIONS, true); if ($status === false) { $query->whereRaw('1 = 0'); } elseif ($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', $status); } } elseif ($headerFilterColumn === 'account_should_keep') { if ($headerFilterValue !== FinanceLedgerAccountShouldKeep::NO) { $query->whereRaw('1 = 0'); } } elseif ($headerFilterColumn === 'handling_method') { $method = array_search($headerFilterValue, $handlingMethods, true); if ($method === false) { $query->whereRaw('1 = 0'); } else { $query->where('account_todo.handling_method', $method); } } elseif ($headerFilterColumn === 'owner_name') { $query->where('account_todo.owner_name', 'like', '%' . $headerFilterValue . '%'); } elseif ($headerFilterColumn === 'started_at' || $headerFilterColumn === 'completed_at') { $query->where('account_todo.' . $headerFilterColumn, 'like', $headerFilterValue . '%'); } elseif ($headerFilterColumn === 'current_progress') { $progress = array_search($headerFilterValue, $completionStatuses, true); if ($progress === false) { $query->whereRaw('1 = 0'); } elseif ($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', $progress); } } } $legalRepresentativeCountSource = in_array($headerFilterColumn, ['', 'legal_representative'], true) ? $headerOptionRows->pluck('company_name') : (clone $query) ->reorder() ->select('accounts.company_name') ->pluck('accounts.company_name'); $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, $headerFilterColumns, true) ? $sortField : ''; $sortDirection = strtolower((string) $request->query('keep_direction', 'asc')); $sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc'; if ($sortField !== '') { if ($sortField === 'legal_representative') { if ($connection->getSchemaBuilder()->hasTable('ops_fys_company_info')) { $legalRepresentativeOrder = $connection->table('ops_fys_company_info') ->select('legalRepresentative') ->whereColumn('businessLicenseName', 'accounts.company_name') ->orderByDesc('Id') ->limit(1); $query->orderBy($legalRepresentativeOrder, $sortDirection); } else { $query->orderBy('accounts.company_name', $sortDirection); } } elseif ($sortField === 'account_type') { $query->orderByRaw( 'CASE WHEN type_override_id IS NOT NULL THEN effective_account_type' . ' ELSE sort_account_type END ' . $sortDirection, ); } elseif ($sortField === 'account_status') { $query->orderByRaw( "CASE COALESCE(effective_account_status, 'normal')" . " WHEN 'normal' THEN 1" . " WHEN 'online_banking_abnormal' THEN 2" . " WHEN 'limited' THEN 3" . " WHEN 'business_deregistered' THEN 4" . ' ELSE 99 END ' . $sortDirection, ); } elseif ($sortField === 'handling_method') { $query->orderByRaw( 'CASE todo_handling_method' . " WHEN 'online' THEN 1" . " WHEN 'offline' THEN 2" . ' ELSE 0 END ' . $sortDirection, ); } elseif ($sortField === 'current_progress') { $query->orderByRaw( "CASE COALESCE(todo_current_progress, 'not_started')" . " WHEN 'not_started' THEN 1" . " WHEN 'in_progress' THEN 2" . " WHEN 'completed' THEN 3" . ' ELSE 99 END ' . $sortDirection, ); } elseif ($sortField === 'owner_name') { $query->orderBy('account_todo.owner_name', $sortDirection); } elseif (in_array($sortField, ['started_at', 'completed_at'], true)) { $query->orderBy('account_todo.' . $sortField, $sortDirection); } elseif ($sortField !== 'account_should_keep') { $query->orderBy('accounts.' . $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) use ($legalRepresentatives) { $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'; $row->legal_representative = $legalRepresentatives[trim((string) $row->company_name)] ?? null; return $row; }); $ownerOptions = self::UNRETAINED_OWNER_OPTIONS; $headerOptionValues = static function (iterable $values, bool $includeEmpty = false): array { $normalized = collect($values) ->map(static fn ($value): string => trim((string) $value)) ->filter(static fn (string $value): bool => $value !== '') ->unique() ->sort(SORT_NATURAL | SORT_FLAG_CASE) ->values() ->all(); $options = $normalized; if ($includeEmpty) { $options = array_merge(['__empty__' => '未填写'], $normalized); } return $options; }; $hasEmpty = static fn (string $column): bool => $headerOptionRows ->contains(static fn ($row): bool => trim((string) ($row->{$column} ?? '')) === ''); $legalRepresentativeCounts = $legalRepresentativeCountSource->countBy( static fn ($companyName): string => trim((string) ($legalRepresentatives[trim((string) $companyName)] ?? '')), ); $legalRepresentativeOptions = []; if (($legalRepresentativeCounts[''] ?? 0) > 0) { $legalRepresentativeOptions[] = '未填写(' . $legalRepresentativeCounts[''] . ')'; } $legalRepresentativeCounts ->except(['']) ->sortKeys(SORT_NATURAL | SORT_FLAG_CASE) ->each(static function ($count, $name) use (&$legalRepresentativeOptions): void { $legalRepresentativeOptions[] = $name . '(' . $count . ')'; }); $keepHeaderOptions = [ 'company_name' => $headerOptionValues($headerOptionRows->pluck('company_name'), $hasEmpty('company_name')), 'account_short' => $headerOptionValues($headerOptionRows->pluck('account_short'), $hasEmpty('account_short')), 'legal_representative' => $legalRepresentativeOptions, 'account_type' => $headerOptionValues($typeOptions, true), 'account_status' => $headerOptionValues(array_values(FinanceLedgerAccountStatus::OPTIONS)), 'account_no' => $headerOptionValues($headerOptionRows->pluck('account_no'), $hasEmpty('account_no')), 'bank_name' => $headerOptionValues($headerOptionRows->pluck('bank_name'), $hasEmpty('bank_name')), 'bank_city_branch' => $headerOptionValues($headerOptionRows->pluck('bank_city_branch'), $hasEmpty('bank_city_branch')), 'account_should_keep' => [FinanceLedgerAccountShouldKeep::NO], 'handling_method' => $headerOptionValues(array_values($handlingMethods)), 'owner_name' => $headerOptionValues($ownerOptions), 'started_at' => [], 'completed_at' => [], 'current_progress' => $headerOptionValues(array_values($completionStatuses)), ]; $keepHeaderPreserve = collect($request->query()) ->except(['page', 'keep_filter_column', 'keep_filter_value']) ->filter(static fn ($value): bool => is_scalar($value) || $value === null) ->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, 'keepHeaderOptions' => $keepHeaderOptions, 'keepHeaderPreserve' => $keepHeaderPreserve, ]); } 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', Rule::in(self::UNRETAINED_OWNER_OPTIONS)], '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']); } private function latestLegalRepresentatives($connection, array $companyNames): array { if ($companyNames === [] || ! $connection->getSchemaBuilder()->hasTable('ops_fys_company_info')) { return []; } $latestCompanyIds = $connection->table('ops_fys_company_info') ->selectRaw('businessLicenseName, MAX(Id) AS latest_id') ->whereIn('businessLicenseName', $companyNames) ->groupBy('businessLicenseName') ->pluck('latest_id', 'businessLicenseName'); if ($latestCompanyIds->isEmpty()) { return []; } return $connection->table('ops_fys_company_info') ->whereIn('Id', $latestCompanyIds->values()->all()) ->get(['businessLicenseName', 'legalRepresentative']) ->mapWithKeys(static fn ($company): array => [ (string) $company->businessLicenseName => trim((string) $company->legalRepresentative), ]) ->all(); } }