diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index bc74fd42..0c877320 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -2,15 +2,27 @@ namespace App\Http\Controllers\Business; -use Illuminate\Routing\Controller; +use App\Utilities\FinanceLedgerAccountStatus; +use App\Utilities\FinanceLedgerAccountType; use App\Utilities\FinanceLedgerAccess; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; +use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; +use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; +use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; +use Symfony\Component\HttpFoundation\StreamedResponse; class FinanceLedger extends Controller { + public function downloadAccountTemplate(FinanceLedgerAccountTemplate $template): StreamedResponse + { + return $template->download(); + } + public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。'); @@ -59,4 +71,171 @@ class FinanceLedger extends Controller 'total' => $connection->table('fin_account_map')->count(), ], 200, ['Cache-Control' => 'private, no-store']); } + + public function updateAccountStatus(Request $request): 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, + ]; + + if ($existing === null) { + $statuses->insert($values + [ + 'account_short' => $accountShort, + 'created_at' => $now, + ]); + } else { + $statuses->where('account_short', $accountShort)->update($values); + } + + 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')); + abort_unless( + $connection->table('fin_accounts')->where('account_short', $accountShort)->exists(), + 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, + ]; + + if ($existing === null) { + $overrides->insert($values + [ + 'account_short' => $accountShort, + 'created_at' => $now, + ]); + } else { + $overrides->where('account_short', $accountShort)->update($values); + } + + return response()->json([ + 'ok' => true, + 'account_short' => $accountShort, + 'account_type' => $accountType, + 'label' => $accountType !== '' ? $accountType : '未填写', + ], 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']); + } } diff --git a/app/Utilities/FinanceLedgerAccess.php b/app/Utilities/FinanceLedgerAccess.php index 309fbbcf..68ed953e 100644 --- a/app/Utilities/FinanceLedgerAccess.php +++ b/app/Utilities/FinanceLedgerAccess.php @@ -28,4 +28,17 @@ class FinanceLedgerAccess return in_array(SystemAccess::roleName($user), $roles, true); } + + /** + * 账户状态、账户类型与账户映射使用同一组财务台账编辑角色。 + */ + public static function canEditAccountStatus(?User $user = null): bool + { + return self::canEditMapping($user); + } + + public static function canEditAccountType(?User $user = null): bool + { + return self::canEditMapping($user); + } } diff --git a/app/Utilities/FinanceLedgerAccountStatus.php b/app/Utilities/FinanceLedgerAccountStatus.php new file mode 100644 index 00000000..1fd25926 --- /dev/null +++ b/app/Utilities/FinanceLedgerAccountStatus.php @@ -0,0 +1,30 @@ + '正常', + self::ONLINE_BANKING_ABNORMAL => '网银异常', + self::LIMITED => '限额', + self::BUSINESS_DEREGISTERED => '工商注销', + ]; + + public static function normalize(?string $status): string + { + $status = trim((string) $status); + + return array_key_exists($status, self::OPTIONS) ? $status : self::NORMAL; + } + + public static function label(?string $status): string + { + return self::OPTIONS[self::normalize($status)]; + } +} diff --git a/app/Utilities/FinanceLedgerAccountType.php b/app/Utilities/FinanceLedgerAccountType.php new file mode 100644 index 00000000..92518e36 --- /dev/null +++ b/app/Utilities/FinanceLedgerAccountType.php @@ -0,0 +1,31 @@ +connection()); + + if (! $schema->hasTable('fin_account_statuses')) { + $schema->create('fin_account_statuses', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('account_short', 100)->unique('fin_account_statuses_account_unique'); + $table->string('status', 40)->default('normal'); + $table->string('updated_by', 100)->nullable(); + $table->timestamps(); + + $table->index(['status'], 'fin_account_statuses_status_index'); + }); + } + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('fin_account_statuses'); + } +}; diff --git a/database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php b/database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php new file mode 100644 index 00000000..54c23c0b --- /dev/null +++ b/database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php @@ -0,0 +1,40 @@ +connection()); + + if (! $schema->hasTable('fin_account_type_overrides')) { + $schema->create('fin_account_type_overrides', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('account_short', 100)->unique('fin_account_type_overrides_account_unique'); + $table->string('account_type', 50)->nullable(); + $table->string('updated_by', 100)->nullable(); + $table->timestamps(); + + $table->index(['account_type'], 'fin_account_type_overrides_type_index'); + }); + } + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('fin_account_type_overrides'); + } +}; diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 428a5b0f..eb46ab7e 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -16,8 +16,12 @@ | `fin_daily_flows` | 每日资金流水 | | `fin_account_map` | 来源值 → 账户简称 | | `fin_account_map_events` | 映射修改审计 | +| `fin_account_statuses` | 账户状态维护(正常、网银异常、限额、工商注销) | +| `fin_account_type_overrides` | 账户类型人工维护值 | -迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`。 +迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`、 +`database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php`、 +`database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php`。 ## 本地导入 @@ -59,9 +63,22 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ - 路由:`/workspace/financial-ledger/ledger` - 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源 -- 写权限:账户映射修改仅系统管理员;其他账号只读 +- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明 +- 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读 - 调度:`finance-ledger:sync-rpt --days=7 --merge` 每天 02:30(Asia/Shanghai) +账户状态独立保存在 `fin_account_statuses`,账户类型人工维护值独立保存在 +`fin_account_type_overrides`,重新导入账户基础数据均不会覆盖;页面修改使用 +异步保存,不触发整页刷新。 + +“账户映射”页支持按店铺台账批量关联:仅匹配 `source = shop`、来源值与 +`fin_shops.shop_name` 完全一致、店铺回款账户简称存在于 `fin_accounts` +的未映射记录。批量操作写入映射审计并重算流水,未精确匹配的来源值保留人工处理。 + +同时支持按账户总表批量关联供应商应付:公司名称与 `fin_accounts.company_name` +完全一致时,只有唯一账户或唯一公户才自动映射;存在多公户或仅私人卡等歧义的公司 +保留人工选择。 + 生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行 `schedule:run` 的容器,调度登记不能替代宿主机 cron。 diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php new file mode 100644 index 00000000..3b059c2e --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php @@ -0,0 +1,198 @@ +makeSpreadsheet(); + + return response()->streamDownload(function () use ($spreadsheet): void { + $writer = new Xlsx($spreadsheet); + $writer->setPreCalculateFormulas(false); + $writer->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, '财务台账-账户总表录入模板.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'no-store, no-cache, must-revalidate', + ]); + } + + private function makeSpreadsheet(): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getProperties() + ->setTitle('财务台账账户总表录入模板') + ->setSubject('账户总表重新录入'); + + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('账户总表'); + $lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS)); + $lastRow = self::MAX_ROWS + 1; + + foreach (self::HEADERS as $index => $header) { + $column = Coordinate::stringFromColumnIndex($index + 1); + $sheet->setCellValue("{$column}1", $header); + $sheet->getColumnDimension($column)->setWidth($this->width($index)); + } + + $sheet->freezePane('A2'); + $sheet->setAutoFilter("A1:{$lastColumn}1"); + $sheet->getRowDimension(1)->setRowHeight(28); + $sheet->getStyle("A1:{$lastColumn}1")->getAlignment() + ->setHorizontal(Alignment::HORIZONTAL_CENTER) + ->setVertical(Alignment::VERTICAL_CENTER) + ->setWrapText(true); + $sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); + $sheet->getStyle("A1:{$lastColumn}1")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FF856632'); + $sheet->getStyle("A1:{$lastColumn}{$lastRow}")->getBorders()->getAllBorders() + ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7'); + + $sheet->getStyle("A2:N{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT); + $sheet->getStyle("O2:Q{$lastRow}")->getNumberFormat()->setFormatCode('#,##0.00'); + $sheet->getStyle("R2:R{$lastRow}")->getNumberFormat()->setFormatCode('0.00'); + $sheet->getStyle("S2:Y{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT); + + $options = $spreadsheet->createSheet(); + $options->setTitle('选项'); + $options->setCellValue('A1', '账户状态'); + $options->setCellValue('B1', '账户类型'); + $statuses = array_values(FinanceLedgerAccountStatus::OPTIONS); + $types = $this->accountTypes(); + foreach ($statuses as $index => $status) { + $options->setCellValue('A' . ($index + 2), $status); + } + foreach ($types as $index => $type) { + $options->setCellValue('B' . ($index + 2), $type); + } + $options->setSheetState(Worksheet::SHEETSTATE_HIDDEN); + + $this->applyListValidation($sheet, 'D', '\'选项\'!$A$2:$A$' . (count($statuses) + 1), '请选择账户状态'); + if ($types !== []) { + $this->applyListValidation($sheet, 'C', '\'选项\'!$B$2:$B$' . (count($types) + 1), '请选择账户类型'); + } + + $notes = $spreadsheet->createSheet(); + $notes->setTitle('填写说明'); + $notes->fromArray([ + ['项目', '说明'], + ['用途', '账户总表重新录入模板,不包含当前账户数据。'], + ['必填', '公司名称、账户简称。'], + ['账户状态', implode(' / ', $statuses)], + ['账户类型', $types !== [] ? implode(' / ', $types) : '留空或按实际填写'], + ['金额', '可用金额、不可用金额、每日可出按数字填写;限额列保留原有组合写法。'], + ['提交前', '请核对账户数、余额合计和期初合计,再导入系统。'], + ], null, 'A1'); + $notes->getColumnDimension('A')->setWidth(16); + $notes->getColumnDimension('B')->setWidth(70); + $notes->getStyle('A1:B1')->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); + $notes->getStyle('A1:B1')->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FF856632'); + $notes->getStyle('A1:B7')->getAlignment()->setVertical(Alignment::VERTICAL_TOP)->setWrapText(true); + $notes->getStyle('A1:B7')->getBorders()->getAllBorders() + ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7'); + $notes->getRowDimension(1)->setRowHeight(26); + + $spreadsheet->setActiveSheetIndex(0); + + return $spreadsheet; + } + + private function applyListValidation( + Worksheet $sheet, + string $column, + string $formula, + string $prompt, + ): void { + $validation = $sheet->getCell("{$column}2")->getDataValidation(); + $validation->setType(DataValidation::TYPE_LIST); + $validation->setErrorStyle(DataValidation::STYLE_STOP); + $validation->setAllowBlank(true); + $validation->setShowInputMessage(true); + $validation->setPromptTitle('选择' . $prompt); + $validation->setPrompt($prompt); + $validation->setShowErrorMessage(true); + $validation->setErrorTitle('选项无效'); + $validation->setError($prompt); + $validation->setFormula1($formula); + $validation->setShowDropDown(true); + + for ($row = 2; $row <= self::MAX_ROWS + 1; $row++) { + $sheet->getCell("{$column}{$row}")->setDataValidation(clone $validation); + } + } + + private function accountTypes(): array + { + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $base = $connection->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(); + $overrides = $connection->table('fin_account_type_overrides') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(); + return FinanceLedgerAccountType::options($base, $overrides); + } + + private function width(int $index): int + { + return match ($index) { + 0, 6 => 30, + 1, 5, 7, 23 => 22, + 2, 3, 4, 8, 17 => 13, + 14, 15, 16 => 16, + default => 18, + }; + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php b/modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php new file mode 100644 index 00000000..e607681b --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php @@ -0,0 +1,150 @@ +connection(); + $accountsByCompany = []; + foreach ($connection->table('fin_accounts')->get(['company_name', 'account_short', 'account_type']) as $account) { + $company = trim((string) $account->company_name); + $accountShort = trim((string) $account->account_short); + if ($company === '' || $accountShort === '') { + continue; + } + $accountsByCompany[$company][$accountShort] = [ + 'account_short' => $accountShort, + 'account_type' => trim((string) $account->account_type), + ]; + } + + $rows = $connection->table('fin_account_map') + ->where('source', 'payable') + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->orderBy('source_value') + ->get(['id', 'source_value']); + + $candidates = []; + $ambiguous = []; + $missing = []; + foreach ($rows as $row) { + $company = trim((string) $row->source_value); + $accounts = array_values($accountsByCompany[$company] ?? []); + if ($accounts === []) { + $missing[] = $company; + continue; + } + if (count($accounts) === 1) { + $candidates[] = $this->candidate($row, $accounts[0]['account_short'], 'single_account'); + continue; + } + + $publicAccounts = array_values(array_filter( + $accounts, + static fn (array $account): bool => $account['account_type'] === '公户', + )); + if (count($publicAccounts) === 1) { + $candidates[] = $this->candidate($row, $publicAccounts[0]['account_short'], 'single_public_account'); + continue; + } + + $ambiguous[] = [ + 'company' => $company, + 'accounts' => array_values(array_map( + static fn (array $account): string => $account['account_short'], + $accounts, + )), + ]; + } + + return [ + 'total' => $rows->count(), + 'eligible' => count($candidates), + 'ambiguous' => count($ambiguous), + 'missing' => count($missing), + 'candidates' => $candidates, + 'ambiguous_values' => $ambiguous, + 'missing_values' => $missing, + ]; + } + + public function apply(?User $user = null): array + { + $preview = $this->preview(); + $connection = $this->connection(); + $userName = (string) ($user?->name ?? ''); + $userId = (int) ($user?->id ?? 0); + $now = now(); + $mapped = 0; + + if ($preview['candidates'] !== []) { + $connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void { + foreach ($preview['candidates'] as $candidate) { + $updated = $connection->table('fin_account_map') + ->where('id', $candidate['id']) + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->update([ + 'account_short' => $candidate['account_short'], + 'updated_by' => $userName, + 'updated_at' => $now, + ]); + if ($updated !== 1) { + continue; + } + + $connection->table('fin_account_map_events')->insert([ + 'source' => 'payable', + 'source_value' => $candidate['source_value'], + 'old_account_short' => null, + 'new_account_short' => $candidate['account_short'], + 'user_id' => $userId, + 'user_name' => $userName, + 'created_at' => $now, + ]); + $mapped++; + } + }); + } + + if ($mapped > 0) { + app(FinanceLedgerCalculator::class)->recalculate(); + } + + return [ + 'total' => $preview['total'], + 'mapped' => $mapped, + 'ambiguous' => $preview['ambiguous'], + 'missing' => $preview['missing'], + ]; + } + + private function candidate(object $row, string $accountShort, string $matchType): array + { + return [ + 'id' => (int) $row->id, + 'source_value' => trim((string) $row->source_value), + 'account_short' => $accountShort, + 'match_type' => $matchType, + ]; + } + + private function connection() + { + return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php b/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php new file mode 100644 index 00000000..7c2a0bbe --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php @@ -0,0 +1,117 @@ +connection(); + $shopAccounts = $connection->table('fin_shops') + ->whereNotNull('account_short') + ->where('account_short', '<>', '') + ->where('account_short', '<>', '/') + ->pluck('account_short', 'shop_name'); + $masterAccounts = array_fill_keys( + $connection->table('fin_accounts')->distinct()->pluck('account_short')->all(), + true, + ); + $rows = $connection->table('fin_account_map') + ->where('source', 'shop') + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->orderBy('source_value') + ->get(['id', 'source_value']); + + $candidates = []; + $unmatched = []; + foreach ($rows as $row) { + $sourceValue = trim((string) $row->source_value); + $accountShort = trim((string) ($shopAccounts[$sourceValue] ?? '')); + if ($accountShort === '' || ! isset($masterAccounts[$accountShort])) { + $unmatched[] = $sourceValue; + continue; + } + + $candidates[] = [ + 'id' => (int) $row->id, + 'source_value' => $sourceValue, + 'account_short' => $accountShort, + ]; + } + + return [ + 'total' => $rows->count(), + 'matched' => count($candidates), + 'unmatched' => count($unmatched), + 'candidates' => $candidates, + 'unmatched_values' => $unmatched, + ]; + } + + public function apply(?User $user = null): array + { + $preview = $this->preview(); + $connection = $this->connection(); + $userName = (string) ($user?->name ?? ''); + $userId = (int) ($user?->id ?? 0); + $now = now(); + $mapped = 0; + + if ($preview['candidates'] !== []) { + $connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void { + foreach ($preview['candidates'] as $candidate) { + $updated = $connection->table('fin_account_map') + ->where('id', $candidate['id']) + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->update([ + 'account_short' => $candidate['account_short'], + 'updated_by' => $userName, + 'updated_at' => $now, + ]); + if ($updated !== 1) { + continue; + } + + $connection->table('fin_account_map_events')->insert([ + 'source' => 'shop', + 'source_value' => $candidate['source_value'], + 'old_account_short' => null, + 'new_account_short' => $candidate['account_short'], + 'user_id' => $userId, + 'user_name' => $userName, + 'created_at' => $now, + ]); + $mapped++; + } + }); + } + + if ($mapped > 0) { + app(FinanceLedgerCalculator::class)->recalculate(); + } + + return [ + 'total' => $preview['total'], + 'mapped' => $mapped, + 'unmatched' => $preview['unmatched'], + ]; + } + + private function connection() + { + return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 4b014023..e027325b 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -2,6 +2,9 @@ namespace Modules\EcommerceFinance\View; +use App\Utilities\FinanceLedgerAccountStatus; +use App\Utilities\FinanceLedgerAccountType; +use App\Utilities\FinanceLedgerAccess; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; @@ -23,6 +26,8 @@ class FinanceLedgerPageData 'sources' => ['title' => '数据来源', 'icon' => 'database'], ]; + private const ACCOUNT_FILTER_EMPTY = '__empty__'; + public function page(?string $tab = null): array { $tab = (string) (request('tab') ?: $tab); @@ -51,7 +56,11 @@ class FinanceLedgerPageData 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), - 'ledgerCanEditMapping' => \App\Utilities\FinanceLedgerAccess::canEditMapping(user()), + 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), + 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, + 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), + 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), + 'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()), ]; } @@ -67,6 +76,74 @@ class FinanceLedgerPageData }); } + $accountShort = trim((string) request('accounts_short', '')); + if ($accountShort !== '') { + $query->where('account_short', 'like', '%' . $accountShort . '%'); + } + + $status = trim((string) request('accounts_status', '')); + if ($status !== '' && array_key_exists($status, FinanceLedgerAccountStatus::OPTIONS)) { + if ($status === FinanceLedgerAccountStatus::NORMAL) { + $nonNormalStatuses = array_values(array_diff( + array_keys(FinanceLedgerAccountStatus::OPTIONS), + [FinanceLedgerAccountStatus::NORMAL], + )); + $query->whereNotIn( + 'account_short', + $this->db()->table('fin_account_statuses') + ->select('account_short') + ->whereIn('status', $nonNormalStatuses), + ); + } else { + $query->whereIn( + 'account_short', + $this->db()->table('fin_account_statuses') + ->select('account_short') + ->where('status', $status), + ); + } + } + + $region = trim((string) request('accounts_region', '')); + if ($region !== '') { + if ($region === self::ACCOUNT_FILTER_EMPTY) { + $query->where(function (Builder $builder): void { + $builder->whereNull('region')->orWhere('region', ''); + }); + } else { + $query->where('region', $region); + } + } + + $accountType = trim((string) request('accounts_type', '')); + if ($accountType !== '') { + $query->where(function (Builder $effectiveType) use ($accountType): void { + $effectiveType->where(function (Builder $baseType) use ($accountType): void { + $baseType->whereNotIn( + 'account_short', + $this->db()->table('fin_account_type_overrides')->select('account_short'), + ); + if ($accountType === self::ACCOUNT_FILTER_EMPTY) { + $baseType->where(function (Builder $blank): void { + $blank->whereNull('account_type')->orWhere('account_type', ''); + }); + } else { + $baseType->where('account_type', $accountType); + } + }); + + $overrideType = $this->db()->table('fin_account_type_overrides')->select('account_short'); + if ($accountType === self::ACCOUNT_FILTER_EMPTY) { + $overrideType->where(function (Builder $blank): void { + $blank->whereNull('account_type')->orWhere('account_type', ''); + }); + } else { + $overrideType->where('account_type', $accountType); + } + $effectiveType->orWhereIn('account_short', $overrideType); + }); + } + $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), @@ -80,6 +157,26 @@ class FinanceLedgerPageData ->paginate(100, ['*'], 'accounts_page') ->withQueryString(); + $accountShorts = $rows->pluck('account_short')->filter()->unique()->values()->all(); + $statuses = $accountShorts === [] + ? collect() + : $this->db()->table('fin_account_statuses') + ->whereIn('account_short', $accountShorts) + ->pluck('status', 'account_short'); + $typeOverrides = $accountShorts === [] + ? collect() + : $this->db()->table('fin_account_type_overrides') + ->whereIn('account_short', $accountShorts) + ->pluck('account_type', 'account_short'); + $rows->getCollection()->transform(static function ($row) use ($statuses, $typeOverrides) { + $row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null); + if ($typeOverrides->has($row->account_short)) { + $row->account_type = (string) ($typeOverrides->get($row->account_short) ?? ''); + } + + return $row; + }); + return ['rows' => $rows, 'summary' => $summary]; } @@ -256,6 +353,24 @@ class FinanceLedgerPageData ->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all(); } + private function accountFilterOptions(): array + { + $baseTypes = $this->db()->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->orderBy('account_type')->pluck('account_type')->all(); + $overrideTypes = $this->db()->table('fin_account_type_overrides') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->orderBy('account_type')->pluck('account_type')->all(); + $types = FinanceLedgerAccountType::options($baseTypes, $overrideTypes); + + return [ + 'regions' => $this->db()->table('fin_accounts') + ->whereNotNull('region')->where('region', '<>', '') + ->distinct()->orderBy('region')->pluck('region')->all(), + 'types' => $types, + ]; + } + private function db() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index a1c52686..3560b03a 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -3,6 +3,18 @@ $tabs = $ledgerTabs ?? []; $activeTab = $ledgerTab ?? 'accounts'; $canEdit = (bool) ($ledgerCanEditMapping ?? false); + $canEditAccountStatus = (bool) ($ledgerCanEditAccountStatus ?? false); + $canEditAccountType = (bool) ($ledgerCanEditAccountType ?? false); + $accountStatusOptions = $ledgerAccountStatusOptions ?? \App\Utilities\FinanceLedgerAccountStatus::OPTIONS; + $accountFilterOptions = $ledgerAccountFilterOptions ?? ['regions' => [], 'types' => []]; + $accountTypeOptions = $accountFilterOptions['types'] ?? []; + $activeAccountFilterCount = count(array_filter([ + request('accounts_q'), + request('accounts_short'), + request('accounts_status'), + request('accounts_region'), + request('accounts_type'), + ], static fn ($value): bool => trim((string) $value) !== '')); $money = static fn ($value): string => $value === null || $value === '' ? '' : number_format((float) $value, 2); $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $tabUrl = static fn (string $tab): string => route('business.pages.show', ['section' => 'financial-ledger', 'page' => 'ledger', 'tab' => $tab]); @@ -36,6 +48,26 @@ .ledger-btn:hover { border-color:var(--vip-content-accent-dark, #856632); color:var(--vip-content-accent-dark, #856632); } .ledger-btn.primary { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; } .ledger-btn.primary:hover { background:#6A5129; color:#fff; } + .ledger-filter-state { display:inline-flex; align-items:center; align-self:flex-end; gap:.28rem; height:2.15rem; padding:0 .58rem; border:1px solid rgba(185,149,79,.42); border-radius:999px; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:700; white-space:nowrap; } + .ledger-filter-state .material-icons-outlined { font-size:.95rem; } + .ledger-filter-state b { display:inline-grid; place-items:center; min-width:1.15rem; height:1.15rem; padding:0 .2rem; border-radius:999px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:.6rem; line-height:1; } + .ledger-column-picker { position:relative; align-self:flex-end; } + .ledger-column-panel { position:absolute; top:calc(100% + .35rem); left:0; z-index:45; width:min(30rem, calc(100vw - 2rem)); max-height:min(25rem, 65vh); overflow:auto; padding:.75rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:#fff; box-shadow:0 4px 12px rgba(23,23,23,.14); } + .ledger-column-panel[hidden] { display:none !important; } + .ledger-column-help { margin:0 0 .55rem; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.5; } + .ledger-column-options { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:.25rem .8rem; } + .ledger-column-option { display:flex; align-items:center; gap:.4rem; min-width:0; min-height:1.8rem; color:var(--vip-content-ink, #171717); font-size:.72rem; cursor:grab; } + .ledger-column-option.is-dragging { opacity:.45; } + .ledger-column-drag { flex:0 0 auto; color:var(--vip-content-muted, #595959); font-size:.95rem; } + .ledger-column-option input { flex:0 0 auto; accent-color:var(--vip-content-accent-dark, #856632); } + .ledger-column-label { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .ledger-column-actions { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-top:.55rem; padding-top:.55rem; border-top:1px solid #e8e3da; color:var(--vip-content-muted, #595959); font-size:.68rem; } + .ledger-column-action-group { display:flex; gap:.4rem; } + .ledger-column-actions button { height:1.8rem; padding:0 .6rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-accent-dark, #856632); font:inherit; font-weight:600; cursor:pointer; } + .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } + .ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; } + .ledger-mapping-bulk .ledger-btn { height:1.95rem; } + .ledger-download-status { flex-basis:100%; color:var(--vip-success, #53793E); font-size:.68rem; font-weight:600; line-height:1.4; text-align:right; } .ledger-summary { display:flex; flex-wrap:wrap; gap:.6rem 1.5rem; padding:.7rem 1rem; margin-bottom:.85rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-summary__item { display:flex; flex-direction:column; gap:.12rem; min-width:7rem; } .ledger-summary__item span { color:var(--vip-content-muted, #595959); font-size:.68rem; } @@ -46,16 +78,34 @@ .ledger-table th, .ledger-table td { padding:.52rem .6rem; border-right:1px solid #e8e3da; border-bottom:1px solid #e8e3da; vertical-align:top; white-space:nowrap; } .ledger-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-ink, #171717); font-weight:700; text-align:left; } .ledger-table tr:hover td { background:#fcfaf4; } + .ledger-accounts-table tbody td { padding-top:.2rem; padding-bottom:.2rem; vertical-align:middle; } .ledger-table th:first-child, .ledger-table td:first-child { position:sticky; left:0; z-index:2; background:#fff; } .ledger-table th:first-child { z-index:3; background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-table tr:hover td:first-child { background:#fcfaf4; } .ledger-table td.num, .ledger-table th.num { text-align:right; font-variant-numeric:tabular-nums; } + .ledger-accounts-table thead th, + .ledger-accounts-table thead th.num { text-align:center; } + .ledger-accounts-table { min-width:max-content; } + .ledger-accounts-table .is-column-hidden { display:none !important; } .ledger-table td.wrap { white-space:normal; min-width:14rem; max-width:22rem; line-height:1.5; } .ledger-table .muted { color:var(--vip-content-muted, #595959); } .ledger-table .danger { color:var(--vip-danger, #B80000); } .ledger-table .success { color:var(--vip-success, #53793E); } + .ledger-accounts-table-wrap { max-height:calc(100vh - 390px); min-height:20rem; } + #ledger-panel-accounts[aria-busy="true"] .ledger-accounts-table-wrap { opacity:.58; pointer-events:none; } .ledger-pagination { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.65rem .9rem; color:var(--vip-content-muted, #595959); font-size:.72rem; } .ledger-pagination a { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } + .ledger-pagination__controls { display:flex; align-items:center; justify-content:flex-end; gap:.45rem; margin-left:auto; } + .ledger-page-box { display:inline-flex; align-items:center; gap:.1rem; height:1.7rem; } + .ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.2rem; height:1.5rem; padding:0; border:0; background:transparent; color:var(--vip-content-accent-dark, #856632); font:inherit; text-decoration:none; } + .ledger-page-box__arrow:hover { color:#6A5129; } + .ledger-page-box__arrow:focus:not(:focus-visible) { outline:none; } + .ledger-page-box__arrow:focus-visible { outline:1px solid var(--vip-content-accent-dark, #856632); outline-offset:0; border-radius:2px; } + .ledger-page-box__triangle { width:0; height:0; border-top:5px solid transparent; border-bottom:5px solid transparent; } + .ledger-page-box__triangle.is-previous { border-right:8px solid currentColor; } + .ledger-page-box__triangle.is-next { border-left:8px solid currentColor; } + .ledger-page-box__arrow.is-disabled { color:#b9b3a9; cursor:not-allowed; } + .ledger-page-box__current { min-width:2.2rem; padding:0 .15rem; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; } .ledger-empty { padding:3.5rem 1rem; text-align:center; color:var(--vip-content-muted, #595959); } .ledger-progress { display:flex; align-items:center; gap:.65rem; min-width:15rem; } .ledger-progress__track { flex:1; height:.45rem; overflow:hidden; border-radius:999px; background:#e8e3da; } @@ -64,6 +114,22 @@ .ledger-map-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-map-input.is-saving { border-color:var(--vip-content-accent, #B9954F); background:#fffdf6; } .ledger-map-input.is-error { border-color:var(--vip-danger, #B80000); background:#fff5f5; } + .ledger-table .ledger-status-column { width:4.6rem; min-width:4.6rem; padding-right:.2rem; padding-left:.2rem; text-align:center; } + .ledger-inline-select { display:block; height:1.75rem; margin:0 auto; padding:0 .1rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-size:.68rem; font-weight:600; text-align:center; text-align-last:center; cursor:pointer; } + .ledger-status-select { width:4.15rem; min-width:4.15rem; } + .ledger-type-select { width:5.2rem; min-width:5.2rem; } + .ledger-inline-select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } + .ledger-inline-select[data-status="normal"] { border-color:rgba(83,121,62,.38); background:#f3f7ee; color:var(--vip-success, #53793E); } + .ledger-inline-select[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.34); background:#fff5f5; color:var(--vip-danger, #B80000); } + .ledger-inline-select[data-status="limited"] { border-color:rgba(147,95,7,.36); background:#fff8eb; color:var(--vip-warning, #935F07); } + .ledger-inline-select[data-status="business_deregistered"] { border-color:rgba(89,89,89,.32); background:#f3f1ec; color:var(--vip-content-muted, #595959); } + .ledger-inline-select.is-saving { opacity:.62; cursor:wait; } + .ledger-inline-select.is-error { border-color:var(--vip-danger, #B80000); box-shadow:0 0 0 3px rgba(184,0,0,.12); } + .ledger-status-badge { display:inline-flex; align-items:center; justify-content:center; min-height:1.65rem; padding:.1rem .3rem; border:1px solid transparent; border-radius:999px; font-size:.68rem; font-weight:700; } + .ledger-status-badge[data-status="normal"] { border-color:rgba(83,121,62,.28); background:#f3f7ee; color:var(--vip-success, #53793E); } + .ledger-status-badge[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.24); background:#fff5f5; color:var(--vip-danger, #B80000); } + .ledger-status-badge[data-status="limited"] { border-color:rgba(147,95,7,.26); background:#fff8eb; color:var(--vip-warning, #935F07); } + .ledger-status-badge[data-status="business_deregistered"] { border-color:rgba(89,89,89,.24); background:#f3f1ec; color:var(--vip-content-muted, #595959); } .ledger-toast { position:fixed; right:1.5rem; bottom:1.5rem; z-index:80; max-width:24rem; padding:.7rem 1rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.5rem; background:#fff; box-shadow:0 4px 12px rgba(23,23,23,.15); font-size:.78rem; display:none; } .ledger-toast.is-visible { display:block; } .ledger-toast.is-error { border-color:var(--vip-danger, #B80000); color:var(--vip-danger, #B80000); } @@ -102,29 +168,88 @@ @@ -204,7 +329,19 @@
refresh重置
-
{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}
+
+ {{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }} + @if ($canEdit) +
+ + +
+ @endif +
@forelse (($mapping['rows'] ?? collect()) as $row)@empty@endforelse @@ -255,15 +392,282 @@ if (window.location.hash === '#mapping') showTab('mapping', false); const toast = page.querySelector('[data-ledger-toast]'); - const notify = (message, error = false) => { + const notify = (message, error = false, persistent = false) => { if (!toast) return; toast.textContent = message; toast.classList.toggle('is-error', error); toast.classList.add('is-visible'); window.clearTimeout(notify.timer); - notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); + if (!persistent) { + notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); + } }; + const accountTemplateDownload = page.querySelector('[data-account-template-download]'); + accountTemplateDownload?.addEventListener('click', () => { + const status = page.querySelector('[data-account-template-download-status]'); + if (status) { + status.hidden = false; + status.textContent = '下载已开始,请查看浏览器下载目录:财务台账-账户总表录入模板.xlsx'; + } + }); + + const accountPanel = page.querySelector('#ledger-panel-accounts'); + const accountColumnPicker = page.querySelector('[data-ledger-column-picker]'); + const accountColumnPanel = accountColumnPicker?.querySelector('[data-ledger-column-panel]'); + const accountColumnOptions = accountColumnPicker?.querySelector('[data-ledger-column-options]'); + const accountColumnToggle = accountColumnPicker?.querySelector('[data-ledger-column-toggle]'); + const accountColumnCount = accountColumnPicker?.querySelector('[data-ledger-column-count]'); + const accountColumnKey = accountColumnPicker?.dataset.columnStorageKey || 'caiwu.financial-ledger.accounts.columns'; + const accountColumnHeaders = () => Array.from( + accountPanel?.querySelectorAll('.ledger-accounts-table thead th') || [], + ); + const initialAccountColumnHeaders = accountColumnHeaders(); + const initialAccountColumnKeys = initialAccountColumnHeaders.map((_, index) => 'account-column-' + index); + initialAccountColumnHeaders.forEach((header, index) => { + header.dataset.ledgerColumnKey = initialAccountColumnKeys[index]; + }); + let visibleAccountColumns = new Set(initialAccountColumnKeys); + let accountColumnOrder = [...initialAccountColumnKeys]; + + const normaliseColumnKeys = (values, fallback) => { + const valid = new Set(initialAccountColumnKeys); + const result = Array.isArray(values) + ? [...new Set(values.filter((value) => valid.has(value)))] + : []; + fallback.forEach((value) => { + if (!result.includes(value)) result.push(value); + }); + + return result; + }; + + const applyAccountColumnLayout = () => { + const table = accountPanel?.querySelector('.ledger-accounts-table'); + if (!table) return; + const headerRow = table.tHead?.rows?.[0]; + if (!headerRow) return; + const headersByKey = new Map( + [...headerRow.children].map((header) => [header.dataset.ledgerColumnKey, header]), + ); + accountColumnOrder.forEach((key) => { + const header = headersByKey.get(key); + if (header) headerRow.appendChild(header); + }); + + const visible = new Set(visibleAccountColumns); + table.querySelectorAll('thead th').forEach((header) => { + header.classList.toggle('is-column-hidden', !visible.has(header.dataset.ledgerColumnKey)); + }); + table.querySelectorAll('tbody tr').forEach((row) => { + [...row.children].forEach((cell, index) => { + if (!cell.dataset.ledgerColumnKey) { + cell.dataset.ledgerColumnKey = initialAccountColumnKeys[index] || ('account-column-extra-' + index); + } + }); + const cellsByKey = new Map( + [...row.children].map((cell) => [cell.dataset.ledgerColumnKey, cell]), + ); + accountColumnOrder.forEach((key) => { + const cell = cellsByKey.get(key); + if (cell) row.appendChild(cell); + }); + [...row.children].forEach((cell) => { + cell.classList.toggle('is-column-hidden', !visible.has(cell.dataset.ledgerColumnKey)); + }); + }); + if (accountColumnCount) { + accountColumnCount.textContent = '显示 ' + visible.size + ' / ' + initialAccountColumnKeys.length + ' 列'; + } + }; + + const saveAccountColumnPreferences = () => { + try { + localStorage.setItem(accountColumnKey, JSON.stringify({ + visible: [...visibleAccountColumns], + order: accountColumnOrder, + })); + } catch (_) {} + }; + + if (accountColumnPicker && accountColumnPanel && accountColumnOptions && accountColumnToggle) { + const headersByKey = new Map( + initialAccountColumnHeaders.map((header, index) => [initialAccountColumnKeys[index], header]), + ); + try { + const saved = JSON.parse(localStorage.getItem(accountColumnKey) || 'null'); + const savedVisible = Array.isArray(saved?.visible) ? saved.visible : initialAccountColumnKeys; + visibleAccountColumns = new Set(normaliseColumnKeys(savedVisible, initialAccountColumnKeys)); + accountColumnOrder = normaliseColumnKeys(saved?.order, initialAccountColumnKeys); + } catch (_) { + visibleAccountColumns = new Set(initialAccountColumnKeys); + accountColumnOrder = [...initialAccountColumnKeys]; + } + + accountColumnOrder.forEach((key) => { + const header = headersByKey.get(key); + if (!header) return; + const label = document.createElement('label'); + label.className = 'ledger-column-option'; + label.draggable = true; + label.dataset.ledgerColumnKey = key; + const handle = document.createElement('span'); + handle.className = 'material-icons-outlined ledger-column-drag'; + handle.setAttribute('aria-hidden', 'true'); + handle.textContent = 'drag_indicator'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = key; + checkbox.checked = visibleAccountColumns.has(key); + checkbox.dataset.ledgerColumnCheck = 'true'; + const text = document.createElement('span'); + text.className = 'ledger-column-label'; + text.textContent = header.textContent.trim() || '未命名字段'; + label.append(handle, checkbox, text); + accountColumnOptions.appendChild(label); + }); + + accountColumnOptions.addEventListener('change', (event) => { + const checkbox = event.target.closest('[data-ledger-column-check]'); + if (!checkbox) return; + const next = Array.from(accountColumnOptions.querySelectorAll('[data-ledger-column-check]:checked')) + .map((item) => item.value); + if (next.length === 0) { + checkbox.checked = true; + notify('账户总表至少保留一列', true); + return; + } + visibleAccountColumns = new Set(next); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); + }); + + accountColumnToggle.addEventListener('click', () => { + const open = accountColumnPanel.hidden; + accountColumnPanel.hidden = !open; + accountColumnToggle.setAttribute('aria-expanded', String(open)); + }); + accountColumnPicker.querySelector('[data-ledger-column-all]')?.addEventListener('click', () => { + accountColumnOptions.querySelectorAll('[data-ledger-column-check]').forEach((checkbox) => { + checkbox.checked = true; + }); + visibleAccountColumns = new Set(initialAccountColumnKeys); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); + }); + accountColumnPicker.querySelector('[data-ledger-column-reset]')?.addEventListener('click', () => { + visibleAccountColumns = new Set(initialAccountColumnKeys); + accountColumnOrder = [...initialAccountColumnKeys]; + accountColumnOptions.querySelectorAll('[data-ledger-column-check]').forEach((checkbox) => { + checkbox.checked = true; + }); + accountColumnOrder.forEach((key) => { + const option = accountColumnOptions.querySelector('[data-ledger-column-key="' + key + '"]'); + if (option) accountColumnOptions.appendChild(option); + }); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); + }); + + let draggedColumnOption = null; + const commitColumnOptionOrder = () => { + accountColumnOrder = Array.from( + accountColumnOptions.querySelectorAll('[data-ledger-column-key]'), + (option) => option.dataset.ledgerColumnKey, + ); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); + }; + accountColumnOptions.querySelectorAll('[data-ledger-column-key]').forEach((option) => { + option.addEventListener('dragstart', () => { + draggedColumnOption = option; + option.classList.add('is-dragging'); + }); + option.addEventListener('dragover', (event) => { + event.preventDefault(); + if (!draggedColumnOption || draggedColumnOption === option) return; + const rect = option.getBoundingClientRect(); + const insertAfter = event.clientY > rect.top + rect.height / 2; + accountColumnOptions.insertBefore( + draggedColumnOption, + insertAfter ? option.nextSibling : option, + ); + }); + option.addEventListener('dragend', () => { + option.classList.remove('is-dragging'); + draggedColumnOption = null; + commitColumnOptionOrder(); + }); + option.addEventListener('keydown', (event) => { + if (!event.altKey || !['ArrowUp', 'ArrowDown'].includes(event.key)) return; + event.preventDefault(); + const ordered = Array.from(accountColumnOptions.querySelectorAll('[data-ledger-column-key]')); + const index = ordered.indexOf(option); + const next = Math.max(0, Math.min(ordered.length - 1, index + (event.key === 'ArrowUp' ? -1 : 1))); + if (next === index) return; + if (next < index) accountColumnOptions.insertBefore(option, ordered[next]); + else accountColumnOptions.insertBefore(option, ordered[next].nextSibling); + commitColumnOptionOrder(); + option.querySelector('input')?.focus(); + }); + }); + + document.addEventListener('click', (event) => { + if (accountColumnPanel.hidden || accountColumnPicker.contains(event.target)) return; + accountColumnPanel.hidden = true; + accountColumnToggle.setAttribute('aria-expanded', 'false'); + }); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || accountColumnPanel.hidden) return; + accountColumnPanel.hidden = true; + accountColumnToggle.setAttribute('aria-expanded', 'false'); + }); + applyAccountColumnLayout(); + } + + const postMappingBulk = async (url) => { + const response = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '操作失败'); + + return payload; + }; + page.querySelectorAll('[data-mapping-bulk]').forEach((button) => { + button.addEventListener('click', async () => { + const kind = button.dataset.mappingKind; + const isPayable = kind === 'payable'; + const label = isPayable ? '供应商应付' : '店铺提现'; + button.disabled = true; + try { + const preview = await postMappingBulk(button.dataset.previewUrl); + const autoMatched = Number(isPayable ? preview.eligible : preview.matched) || 0; + const incomplete = Number(isPayable ? preview.ambiguous : preview.unmatched) || 0; + if (autoMatched === 0) { + notify('没有可按“' + label + '”自动关联的未映射记录,剩余 ' + incomplete + ' 条需手工处理。', true); + return; + } + if (!window.confirm('“' + label + '”可自动关联 ' + autoMatched + ' 条,尚有 ' + incomplete + ' 条无法唯一匹配。确定继续吗?')) { + return; + } + const result = await postMappingBulk(button.dataset.applyUrl); + const remaining = Number(isPayable ? result.ambiguous : result.unmatched) || 0; + notify('已按“' + label + '”关联 ' + result.mapped + ' 条,剩余 ' + remaining + ' 条需人工确认。'); + window.setTimeout(() => window.location.reload(), 900); + } catch (error) { + notify(error.message || '批量关联失败', true); + } finally { + button.disabled = false; + } + }); + }); + const inputs = Array.from(page.querySelectorAll('[data-mapping-input]')); inputs.forEach((input) => { let previous = input.value; @@ -310,5 +714,128 @@ } }); }); + + const bindInlineAccountSelect = (selector, field, successLabel) => { + const selects = Array.from(page.querySelectorAll(selector)); + selects.forEach((select) => { + select.dataset.previousValue = select.value; + select.addEventListener('change', async () => { + const value = select.value; + const previous = select.dataset.previousValue ?? value; + const related = selects.filter((item) => item.dataset.accountShort === select.dataset.accountShort); + related.forEach((item) => { + item.classList.add('is-saving'); + item.classList.remove('is-error'); + }); + try { + const response = await fetch(select.dataset.url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + body: JSON.stringify({ + account_short: select.dataset.accountShort, + [field]: value, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '保存失败'); + related.forEach((item) => { + item.value = value; + item.dataset.previousValue = value; + if (field === 'status') item.dataset.status = value; + }); + notify(successLabel + ':' + (payload.label || '')); + } catch (error) { + select.value = previous; + if (field === 'status') select.dataset.status = previous; + select.classList.add('is-error'); + notify(error.message || successLabel + '失败', true); + } finally { + related.forEach((item) => item.classList.remove('is-saving')); + } + }); + }); + }; + bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); + bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); + + let accountPageRequest = null; + const bindAccountPagination = () => { + if (!accountPanel) return; + accountPanel.querySelectorAll('.ledger-page-box__arrow[href]').forEach((link) => { + if (link.dataset.ajaxBound === '1') return; + link.dataset.ajaxBound = '1'; + link.addEventListener('click', async (event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; + event.preventDefault(); + + const currentBody = accountPanel.querySelector('.ledger-accounts-table tbody'); + const currentSummary = accountPanel.querySelector('.ledger-summary'); + const currentPagination = accountPanel.querySelector('.ledger-pagination'); + const tableWrap = accountPanel.querySelector('.ledger-accounts-table-wrap'); + if (!currentBody || !currentSummary || !currentPagination || !tableWrap) { + window.location.assign(link.href); + return; + } + + const tableScrollTop = tableWrap.scrollTop; + const tableScrollLeft = tableWrap.scrollLeft; + accountPageRequest?.abort(); + const request = new AbortController(); + accountPageRequest = request; + const targetPage = new URL(link.href, window.location.href).searchParams.get('accounts_page'); + const loadingMessage = targetPage ? '正在加载第 ' + targetPage + ' 页…' : '正在加载下一页…'; + accountPanel.setAttribute('aria-busy', 'true'); + notify(loadingMessage, false, true); + try { + const response = await fetch(link.href, { + credentials: 'same-origin', + headers: { + 'Accept': 'text/html', + 'X-Requested-With': 'XMLHttpRequest', + }, + signal: request.signal, + }); + if (!response.ok) throw new Error('分页加载失败(HTTP ' + response.status + ')'); + + const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html'); + const nextPanel = nextDocument.querySelector('#ledger-panel-accounts'); + const nextBody = nextPanel?.querySelector('.ledger-accounts-table tbody'); + const nextSummary = nextPanel?.querySelector('.ledger-summary'); + const nextPagination = nextPanel?.querySelector('.ledger-pagination'); + if (!nextBody || !nextSummary || !nextPagination) throw new Error('分页内容解析失败'); + + currentBody.replaceWith(document.importNode(nextBody, true)); + currentSummary.replaceWith(document.importNode(nextSummary, true)); + currentPagination.replaceWith(document.importNode(nextPagination, true)); + if (window.history?.replaceState) { + window.history.replaceState({ ...window.history.state, ledgerAccountsAjax: true }, '', link.href); + } + + const nextTableWrap = accountPanel.querySelector('.ledger-accounts-table-wrap'); + if (nextTableWrap) { + nextTableWrap.scrollTop = tableScrollTop; + nextTableWrap.scrollLeft = tableScrollLeft; + } + bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); + bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); + applyAccountColumnLayout(); + bindAccountPagination(); + notify(targetPage ? '已加载第 ' + targetPage + ' 页' : '下一页已加载'); + } catch (error) { + if (error.name !== 'AbortError') notify(error.message || '分页加载失败', true); + } finally { + if (accountPageRequest === request) { + accountPanel.removeAttribute('aria-busy'); + } + } + }); + }); + }; + bindAccountPagination(); })(); diff --git a/routes/admin.php b/routes/admin.php index c7018745..cab1e601 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -641,6 +641,34 @@ Route::post('workspace/financial-ledger/mapping/{id}', [\App\Http\Controllers\Bu ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.mapping.update'); +Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountStatus']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.accounts.status'); + +Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountType']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.accounts.type'); + +Route::get('workspace/financial-ledger/accounts/template', [\App\Http\Controllers\Business\FinanceLedger::class, 'downloadAccountTemplate']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.accounts.template'); + +Route::post('workspace/financial-ledger/mapping/shop/preview', [\App\Http\Controllers\Business\FinanceLedger::class, 'previewShopMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.shop.preview'); + +Route::post('workspace/financial-ledger/mapping/shop/apply', [\App\Http\Controllers\Business\FinanceLedger::class, 'applyShopMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.shop.apply'); + +Route::post('workspace/financial-ledger/mapping/payable/preview', [\App\Http\Controllers\Business\FinanceLedger::class, 'previewPayableMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.payable.preview'); + +Route::post('workspace/financial-ledger/mapping/payable/apply', [\App\Http\Controllers\Business\FinanceLedger::class, 'applyPayableMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.payable.apply'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php new file mode 100644 index 00000000..4cef3470 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -0,0 +1,309 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + DB::connection('business_mirror')->table('fin_accounts')->insert([ + 'company_name' => '测试财务公司', + 'account_short' => '测试账户', + 'account_no' => '6222000000000001', + 'bank_name' => '测试银行', + ]); + } + + private function pageUrl(): string + { + return route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + ]); + } + + private function statusUrl(): string + { + return route('business.financial-ledger.accounts.status', [ + 'company_id' => $this->company->id, + ]); + } + + private function typeUrl(): string + { + return route('business.financial-ledger.accounts.type', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_account_table_renders_status_selector_with_four_options(): void + { + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSee('账户状态') + ->assertSee('data-account-status', false) + ->assertSee('正常') + ->assertSee('网银异常') + ->assertSee('限额') + ->assertSee('工商注销') + ->assertSee('name="accounts_short"', false) + ->assertSee('name="accounts_status"', false) + ->assertSee('name="accounts_region"', false) + ->assertSee('name="accounts_type"', false) + ->assertSee('data-account-type', false) + ->assertSee('ledger-type-select', false) + ->assertSee('data-mapping-bulk', false) + ->assertSee('导出录入模板') + ->assertSee('data-account-template-download', false) + ->assertSee('data-account-template-download-status', false) + ->assertSee('', false) + ->assertSee('网商银行') + ->assertSee('ledger-accounts-table-wrap', false) + ->assertSee('ledger-pagination__controls', false) + ->assertSee('ledger-page-box', false) + ->assertSee('1 / 1', false) + ->assertSee('data-ledger-column-picker', false) + ->assertSee('data-ledger-column-panel', false) + ->assertSee('列设置') + ->assertSee('拖动字段可调整列顺序') + ->assertSee('恢复默认') + ->assertSee('可用金额合计') + ->assertSee('不可用金额合计') + ->assertSee('可用金额 − 不可用金额') + ->assertSee('未填写') + ->assertDontSee('data-account-filter-state', false) + ->assertSee('value="normal" selected', false); + } + + public function test_account_pagination_lists_page_urls_with_current_filter(): void + { + $rows = []; + for ($index = 1; $index <= 101; $index++) { + $rows[] = [ + 'company_name' => '分页测试公司', + 'account_short' => 'page-test-' . $index, + ]; + } + DB::connection('business_mirror')->table('fin_accounts')->insert($rows); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_short' => 'page-test', + ])) + ->assertOk() + ->assertSee('ledger-page-box', false) + ->assertSee('aria-label="下一页"', false) + ->assertSee('accounts_page=2', false) + ->assertSee('accounts_short=page-test', false); + } + + public function test_account_filters_narrow_by_short_status_region_and_type(): void + { + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + [ + 'company_name' => '筛选测试公司甲', + 'account_short' => '筛选账户甲', + 'region' => '上海', + 'account_type' => '公户', + ], + [ + 'company_name' => '筛选测试公司乙', + 'account_short' => '筛选账户乙', + 'region' => '杭州', + 'account_type' => '个人卡', + ], + ]); + $connection->table('fin_account_statuses')->insert([ + 'account_short' => '筛选账户乙', + 'status' => 'limited', + 'updated_by' => 'Test User', + ]); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_short' => '筛选账户', + 'accounts_status' => 'limited', + 'accounts_region' => '杭州', + 'accounts_type' => '个人卡', + ])) + ->assertOk() + ->assertSee('data-account-short="筛选账户乙"', false) + ->assertDontSee('data-account-short="筛选账户甲"', false) + ->assertSee('data-account-filter-state', false) + ->assertSee('筛选中') + ->assertSee('value="limited" selected', false); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_short' => '筛选账户', + 'accounts_status' => 'normal', + ])) + ->assertOk() + ->assertSee('data-account-short="筛选账户甲"', false) + ->assertDontSee('data-account-short="筛选账户乙"', false); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_short' => '测试账户', + 'accounts_status' => 'normal', + 'accounts_region' => '__empty__', + 'accounts_type' => '__empty__', + ])) + ->assertOk() + ->assertSee('data-account-short="测试账户"', false); + } + + public function test_account_type_can_be_saved_and_filtered_by_effective_value(): void + { + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + [ + 'company_name' => '类型测试公司甲', + 'account_short' => '类型账户甲', + 'account_type' => '公户', + ], + [ + 'company_name' => '类型测试公司乙', + 'account_short' => '类型账户乙', + 'account_type' => '个人卡', + ], + [ + 'company_name' => '类型测试公司丙', + 'account_short' => '类型账户丙', + 'account_type' => '支付宝', + ], + ]); + + $this->loginAs()->postJson($this->typeUrl(), [ + 'account_short' => '类型账户甲', + 'account_type' => '网商银行', + ]) + ->assertOk() + ->assertJsonPath('account_type', '网商银行') + ->assertJsonPath('label', '网商银行'); + + $this->loginAs()->postJson($this->typeUrl(), [ + 'account_short' => '类型账户甲', + 'account_type' => '个人卡', + ])->assertOk(); + + $this->assertDatabaseCount('fin_account_type_overrides', 1, 'business_mirror'); + $this->assertDatabaseHas('fin_account_type_overrides', [ + 'account_short' => '类型账户甲', + 'account_type' => '个人卡', + 'updated_by' => 'Test User', + ], 'business_mirror'); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_type' => '个人卡', + ])) + ->assertOk() + ->assertSee('data-account-short="类型账户甲"', false) + ->assertSee('data-account-short="类型账户乙"', false) + ->assertDontSee('data-account-short="类型账户丙"', false) + ->assertSee('value="个人卡" selected', false); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_type' => '公户', + ])) + ->assertOk() + ->assertDontSee('data-account-short="类型账户甲"', false); + + $this->withExceptionHandling()->loginAs()->postJson($this->typeUrl(), [ + 'account_short' => '类型账户甲', + 'account_type' => '不存在的类型', + ])->assertUnprocessable()->assertJsonPath('message', '请选择现有账户类型。'); + } + + public function test_account_status_can_be_saved_and_updated_without_duplicate_rows(): void + { + $payload = [ + 'account_short' => '测试账户', + 'status' => 'limited', + ]; + + $this->loginAs()->postJson($this->statusUrl(), $payload) + ->assertOk() + ->assertJsonPath('status', 'limited') + ->assertJsonPath('label', '限额'); + + $this->loginAs()->postJson($this->statusUrl(), [ + 'account_short' => '测试账户', + 'status' => 'normal', + ])->assertOk()->assertJsonPath('label', '正常'); + + $this->assertDatabaseCount('fin_account_statuses', 1, 'business_mirror'); + $this->assertDatabaseHas('fin_account_statuses', [ + 'account_short' => '测试账户', + 'status' => 'normal', + 'updated_by' => 'Test User', + ], 'business_mirror'); + + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSee('value="normal" selected', false); + } + + public function test_invalid_status_or_account_is_rejected(): void + { + $this->withExceptionHandling()->loginAs()->postJson($this->statusUrl(), [ + 'account_short' => '测试账户', + 'status' => 'unknown', + ])->assertUnprocessable()->assertJsonValidationErrors('status'); + + $this->withExceptionHandling()->loginAs()->postJson($this->statusUrl(), [ + 'account_short' => '不存在的账户', + 'status' => 'limited', + ])->assertUnprocessable()->assertJsonPath('message', '账户简称不存在,请刷新账户总表后重试。'); + + $this->assertDatabaseCount('fin_account_statuses', 0, 'business_mirror'); + } + + public function test_user_without_finance_editor_role_cannot_change_account_status(): void + { + $viewer = user_model_class()::create([ + 'name' => '只读测试用户', + 'email' => 'ledger-viewer-' . uniqid() . '@example.com', + 'password' => 'local-test-password', + 'locale' => 'zh-CN', + 'enabled' => 1, + 'landing_page' => 'dashboard', + 'created_from' => 'core::test', + ]); + $viewer->companies()->attach($this->company->id); + $viewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->withExceptionHandling()->loginAs($viewer)->postJson($this->statusUrl(), [ + 'account_short' => '测试账户', + 'status' => 'limited', + ])->assertForbidden(); + + $this->assertDatabaseCount('fin_account_statuses', 0, 'business_mirror'); + } +} diff --git a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php new file mode 100644 index 00000000..bfecef65 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php @@ -0,0 +1,88 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + DB::connection('business_mirror')->table('fin_accounts')->insert([ + 'company_name' => '模板测试公司', + 'account_short' => '模板测试账户', + 'account_type' => '公户', + ]); + } + + public function test_account_master_template_is_blank_and_contains_page_columns_and_validations(): void + { + $response = $this->loginAs()->get(route('business.financial-ledger.accounts.template', [ + 'company_id' => $this->company->id, + ])); + $response->assertOk(); + $this->assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('content-type'), + ); + + $path = tempnam(sys_get_temp_dir(), 'ledger-template-'); + file_put_contents($path, $response->streamedContent()); + + try { + $spreadsheet = IOFactory::load($path); + $sheet = $spreadsheet->getSheetByName('账户总表'); + $this->assertNotNull($sheet); + $this->assertSame([ + '公司名称', + '账户简称', + '账户类型', + '账户状态', + '地区', + '账号', + '开户行', + '开户城市/网点', + '公司规划', + '后4位', + '单笔限额', + '日限额', + '月限额', + '年限额', + '可用金额', + '不可用金额', + '每日可出', + '预计出清天数', + '提额目标', + '提额状态', + '提额动作', + '已做动作', + '下一步', + '备注', + '责任人', + ], $sheet->rangeToArray('A1:Y1')[0]); + $this->assertNull($sheet->getCell('A2')->getValue()); + $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('C2')->getDataValidation()->getType()); + $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('D2')->getDataValidation()->getType()); + $this->assertSame(Worksheet::SHEETSTATE_HIDDEN, $spreadsheet->getSheetByName('选项')->getSheetState()); + $typeOptions = array_map( + static fn (array $row): string => (string) ($row[0] ?? ''), + $spreadsheet->getSheetByName('选项')->rangeToArray('B2:B20'), + ); + $this->assertContains('网商银行', $typeOptions); + $this->assertNotNull($spreadsheet->getSheetByName('填写说明')); + $spreadsheet->disconnectWorksheets(); + } finally { + @unlink($path); + } + } +} diff --git a/tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php b/tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php new file mode 100644 index 00000000..77389029 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php @@ -0,0 +1,87 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + ['company_name' => '唯一账户测试公司甲', 'account_short' => '甲工行', 'account_type' => '公户'], + ['company_name' => '唯一公户测试公司乙', 'account_short' => '乙中行', 'account_type' => '公户'], + ['company_name' => '唯一公户测试公司乙', 'account_short' => '乙支付宝', 'account_type' => '支付宝'], + ['company_name' => '多公户测试公司丙', 'account_short' => '丙中行', 'account_type' => '公户'], + ['company_name' => '多公户测试公司丙', 'account_short' => '丙华夏', 'account_type' => '公户'], + ]); + $connection->table('fin_account_map')->insert([ + ['source' => 'payable', 'source_value' => '唯一账户测试公司甲', 'account_short' => null], + ['source' => 'payable', 'source_value' => '唯一公户测试公司乙', 'account_short' => null], + ['source' => 'payable', 'source_value' => '多公户测试公司丙', 'account_short' => null], + ['source' => 'payable', 'source_value' => '无账户测试公司丁', 'account_short' => null], + ]); + } + + private function previewUrl(): string + { + return route('business.financial-ledger.mapping.payable.preview', [ + 'company_id' => $this->company->id, + ]); + } + + private function applyUrl(): string + { + return route('business.financial-ledger.mapping.payable.apply', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_preview_and_apply_single_account_or_single_public_account(): void + { + $this->loginAs()->postJson($this->previewUrl()) + ->assertOk() + ->assertJsonPath('total', 4) + ->assertJsonPath('eligible', 2) + ->assertJsonPath('ambiguous', 1) + ->assertJsonPath('missing', 1); + + $this->loginAs()->postJson($this->applyUrl()) + ->assertOk() + ->assertJsonPath('mapped', 2) + ->assertJsonPath('ambiguous', 1) + ->assertJsonPath('missing', 1); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '唯一账户测试公司甲', + 'account_short' => '甲工行', + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '唯一公户测试公司乙', + 'account_short' => '乙中行', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '多公户测试公司丙', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '无账户测试公司丁', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseCount('fin_account_map_events', 2, 'business_mirror'); + } +} diff --git a/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php b/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php new file mode 100644 index 00000000..a53d5e83 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php @@ -0,0 +1,94 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + ['company_name' => '店铺映射测试公司', 'account_short' => '映射账户甲'], + ['company_name' => '店铺映射测试公司', 'account_short' => '映射账户乙'], + ]); + $connection->table('fin_shops')->insert([ + ['shop_name' => '天猫-映射店铺甲', 'account_short' => '映射账户甲'], + ['shop_name' => '天猫-映射店铺乙', 'account_short' => '映射账户乙'], + ['shop_name' => '天猫-映射店铺丙', 'account_short' => '/'], + ]); + $connection->table('fin_account_map')->insert([ + ['source' => 'shop', 'source_value' => '天猫-映射店铺甲', 'account_short' => null], + ['source' => 'shop', 'source_value' => '天猫-映射店铺乙', 'account_short' => null], + ['source' => 'shop', 'source_value' => '天猫-映射店铺丙', 'account_short' => null], + ['source' => 'shop', 'source_value' => '已经映射的店铺', 'account_short' => '映射账户甲'], + ]); + } + + private function previewUrl(): string + { + return route('business.financial-ledger.mapping.shop.preview', [ + 'company_id' => $this->company->id, + ]); + } + + private function applyUrl(): string + { + return route('business.financial-ledger.mapping.shop.apply', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_preview_and_apply_only_exact_shop_name_matches(): void + { + $this->loginAs()->postJson($this->previewUrl()) + ->assertOk() + ->assertJsonPath('total', 3) + ->assertJsonPath('matched', 2) + ->assertJsonPath('unmatched', 1) + ->assertJsonCount(2, 'sample'); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺甲', + 'account_short' => null, + ], 'business_mirror'); + + $this->loginAs()->postJson($this->applyUrl()) + ->assertOk() + ->assertJsonPath('mapped', 2) + ->assertJsonPath('unmatched', 1); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺甲', + 'account_short' => '映射账户甲', + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺乙', + 'account_short' => '映射账户乙', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺丙', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '已经映射的店铺', + 'account_short' => '映射账户甲', + ], 'business_mirror'); + $this->assertDatabaseCount('fin_account_map_events', 2, 'business_mirror'); + } +}
来源原始值笔数收入合计支出合计映射账户简称状态
{{ $sourceLabels[$row->source] ?? $row->source }}{{ $row->source_value }}{{ number_format((int) $row->txn_count) }}{{ $money($row->income_sum) }}{{ $money($row->expense_sum) }}@if ($canEdit)@else{{ $row->account_short ?: '未匹配' }}@endif{{ $row->account_short ? '已映射' : '未匹配' }}
没有匹配的映射记录。
账户简称账户类型账户状态