feat: 完善财务台账账户维护与分页体验 #412

Merged
shanshi merged 25 commits from feature/shanshi-account-status into main 2026-10-21 11:00:57 +08:00
17 changed files with 2082 additions and 18 deletions

View File

@ -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']);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Utilities;
final class FinanceLedgerAccountStatus
{
public const NORMAL = 'normal';
public const ONLINE_BANKING_ABNORMAL = 'online_banking_abnormal';
public const LIMITED = 'limited';
public const BUSINESS_DEREGISTERED = 'business_deregistered';
public const OPTIONS = [
self::NORMAL => '正常',
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)];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Utilities;
final class FinanceLedgerAccountType
{
public const DEFAULT_OPTIONS = [
'个人卡',
'公户',
'支付宝',
'网商银行',
];
public static function options(array ...$groups): array
{
$types = self::DEFAULT_OPTIONS;
foreach ($groups as $group) {
foreach ($group as $type) {
$type = trim((string) $type);
if ($type !== '') {
$types[] = $type;
}
}
}
$types = array_values(array_unique($types));
sort($types, SORT_NATURAL | SORT_FLAG_CASE);
return $types;
}
}

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户状态。
*
* 状态由财务人员在页面维护,独立于可被全量覆盖的 fin_accounts 基础表,
* 因此重新导入《银行当前余额》不会丢失。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户类型人工维护值。
*
* 基础表 fin_accounts 在导入时会被全量覆盖,因此人工调整后的类型单独保存。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -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。

View File

@ -0,0 +1,198 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 财务台账账户总表空白录入模板。
*/
class FinanceLedgerAccountTemplate
{
private const MAX_ROWS = 1000;
private const HEADERS = [
'公司名称',
'账户简称',
'账户类型',
'账户状态',
'地区',
'账号',
'开户行',
'开户城市/网点',
'公司规划',
'后4位',
'单笔限额',
'日限额',
'月限额',
'年限额',
'可用金额',
'不可用金额',
'每日可出',
'预计出清天数',
'提额目标',
'提额状态',
'提额动作',
'已做动作',
'下一步',
'备注',
'责任人',
];
public function download(): StreamedResponse
{
$spreadsheet = $this->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,
};
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use Illuminate\Support\Facades\DB;
/**
* 按账户总表批量建立“供应商应付”公司名称到账户简称的映射。
*
* 公司只有唯一账户,或虽有多个账户但只有一个公户时自动关联;
* 多公户、只有私人卡等歧义情况保留人工选择。
*/
class FinanceLedgerPayableMappingBulk
{
public function preview(): array
{
$connection = $this->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'));
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use Illuminate\Support\Facades\DB;
/**
* 按店铺台账批量建立“店铺提现”来源值到账户简称的映射。
*
* 只处理店铺名称完全一致、回款账户简称存在于账户总表的未映射记录,
* 其余来源值保留人工确认。
*/
class FinanceLedgerShopMappingBulk
{
public function preview(): array
{
$connection = $this->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'));
}
}

View File

@ -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'));

View File

@ -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 @@
<section class="ledger-panel" id="ledger-panel-accounts" role="tabpanel" data-ledger-panel="accounts" @if ($activeTab !== 'accounts') hidden @endif>
<form class="ledger-toolbar" method="GET" action="{{ $tabUrl('accounts') }}">
<input type="hidden" name="tab" value="accounts">
<div class="ledger-field"><label for="accounts-q">关键词</label><input id="accounts-q" name="accounts_q" value="{{ request('accounts_q') }}" placeholder="公司、账户简称、账号、责任人"></div>
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('accounts') }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
<div class="ledger-field"><label for="accounts-q">关键词</label><input id="accounts-q" name="accounts_q" value="{{ request('accounts_q') }}" placeholder="公司、账号、责任人"></div>
<div class="ledger-field" style="min-width:11rem"><label for="accounts-short">账户简称</label><input id="accounts-short" name="accounts_short" list="ledger-account-list" value="{{ request('accounts_short') }}" placeholder="选择或输入"></div>
<div class="ledger-field" style="min-width:8rem"><label for="accounts-status">账户状态</label><select id="accounts-status" name="accounts_status"><option value="">全部状态</option>@foreach ($accountStatusOptions as $statusCode => $statusLabel)<option value="{{ $statusCode }}" @selected(request('accounts_status') === $statusCode)>{{ $statusLabel }}</option>@endforeach</select></div>
<div class="ledger-field" style="min-width:8rem"><label for="accounts-region">地区</label><select id="accounts-region" name="accounts_region"><option value="">全部地区</option>@foreach (($accountFilterOptions['regions'] ?? []) as $region)<option value="{{ $region }}" @selected(request('accounts_region') === $region)>{{ $region }}</option>@endforeach<option value="__empty__" @selected(request('accounts_region') === '__empty__')>未填写</option></select></div>
<div class="ledger-field" style="min-width:8rem"><label for="accounts-type">账户类型</label><select id="accounts-type" name="accounts_type"><option value="">全部类型</option>@foreach (($accountFilterOptions['types'] ?? []) as $type)<option value="{{ $type }}" @selected(request('accounts_type') === $type)>{{ $type }}</option>@endforeach<option value="__empty__" @selected(request('accounts_type') === '__empty__')>未填写</option></select></div>
@if ($activeAccountFilterCount > 0)
<span class="ledger-filter-state" data-account-filter-state role="status" aria-live="polite" title="当前有 {{ $activeAccountFilterCount }} 个筛选条件生效"><span class="material-icons-outlined" aria-hidden="true">filter_alt</span><span>筛选中</span><b>{{ $activeAccountFilterCount }}</b></span>
@endif
<div class="ledger-column-picker" data-ledger-column-picker data-column-storage-key="caiwu.financial-ledger.accounts.preferences.v1.{{ auth()->id() }}.{{ company_id() }}">
<button class="ledger-btn" type="button" data-ledger-column-toggle aria-expanded="false"><span class="material-icons-outlined" aria-hidden="true">view_column</span>列设置</button>
<div class="ledger-column-panel" data-ledger-column-panel role="group" aria-label="选择账户总表展示列" hidden>
<p class="ledger-column-help">拖动字段可调整列顺序;按住 Alt 并使用 ↑ / ↓ 也可移动。</p>
<div class="ledger-column-options" data-ledger-column-options></div>
<div class="ledger-column-actions"><span data-ledger-column-count></span><div class="ledger-column-action-group"><button type="button" data-ledger-column-all>显示全部</button><button type="button" data-ledger-column-reset>恢复默认</button></div></div>
</div>
</div>
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('accounts') }}"><span class="material-icons-outlined">refresh</span>重置</a><a class="ledger-btn" href="{{ route('business.financial-ledger.accounts.template') }}" download data-account-template-download><span class="material-icons-outlined">download</span>导出录入模板</a></div>
<div class="ledger-download-status" data-account-template-download-status role="status" aria-live="polite" hidden>下载已开始,请查看浏览器下载目录。</div>
</form>
<div class="ledger-summary">
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
<div class="ledger-summary__item"><span>当前余额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
<div class="ledger-summary__item"><span>卡住金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></div>
<div class="ledger-summary__item"><span>每日可出金额口径</span><b>余额 − 卡住金额</b></div>
<div class="ledger-summary__item"><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
<div class="ledger-summary__item"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></div>
<div class="ledger-summary__item"><span>每日可出金额口径</span><b>可用金额 − 不可用金额</b></div>
</div>
<div class="ledger-table-wrap">
<table class="ledger-table">
<thead><tr><th>公司名称</th><th>账户简称</th><th>地区</th><th>账号</th><th>开户行</th><th>开户城市/网点</th><th>账户类型</th><th>公司规划</th><th>后4位</th><th>单笔限额</th><th>日限额</th><th>月限额</th><th>年限额</th><th class="num">当前余额</th><th class="num">卡住金额</th><th class="num">每日可出</th><th class="num">预计出清天数</th><th>提额目标</th><th>提额状态</th><th>提额动作</th><th>已做动作</th><th>下一步</th><th>备注</th><th>责任人</th></tr></thead>
<div class="ledger-table-wrap ledger-accounts-table-wrap">
<table class="ledger-table ledger-accounts-table">
<thead><tr><th>公司名称</th><th>账户简称</th><th>账户类型</th><th class="ledger-status-column">账户状态</th><th>地区</th><th>账号</th><th>开户行</th><th>开户城市/网点</th><th>公司规划</th><th>后4位</th><th>单笔限额</th><th>日限额</th><th>月限额</th><th>年限额</th><th class="num">可用金额</th><th class="num">不可用金额</th><th class="num">每日可出</th><th class="num">预计出清天数</th><th>提额目标</th><th>提额状态</th><th>提额动作</th><th>已做动作</th><th>下一步</th><th>备注</th><th>责任人</th></tr></thead>
<tbody>
@forelse ($ledgerAccounts as $row)
<tr>
<td>{{ $row->company_name }}</td><td>{{ $row->account_short }}</td><td>{{ $row->region }}</td><td>{{ $row->account_no }}</td><td>{{ $row->bank_name }}</td><td>{{ $row->bank_city_branch }}</td><td>{{ $row->account_type }}</td><td>{{ $row->company_plan }}</td><td>{{ $row->account_last4 }}</td><td>{{ $row->single_limit }}</td><td>{{ $row->daily_limit }}</td><td>{{ $row->monthly_limit }}</td><td>{{ $row->yearly_limit }}</td><td class="num">{{ $money($row->current_balance) }}</td><td class="num">{{ $money($row->blocked_amount) }}</td><td class="num">{{ $money($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td>{{ $row->raise_target }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
<td>{{ $row->company_name }}</td>
<td>{{ $row->account_short }}</td>
<td>
@if ($canEditAccountType)
<select class="ledger-inline-select ledger-type-select" data-account-type data-account-short="{{ $row->account_short }}" data-url="{{ route('business.financial-ledger.accounts.type') }}" aria-label="账户 {{ $row->account_short }} 类型">
<option value="" @selected(($row->account_type ?? '') === '')>未填写</option>
@foreach ($accountTypeOptions as $accountType)
<option value="{{ $accountType }}" @selected($row->account_type === $accountType)>{{ $accountType }}</option>
@endforeach
</select>
@else
{{ $row->account_type ?: '未填写' }}
@endif
</td>
<td class="ledger-status-column">
@if ($canEditAccountStatus)
<select class="ledger-inline-select ledger-status-select" data-account-status data-account-short="{{ $row->account_short }}" data-status="{{ $row->account_status }}" data-url="{{ route('business.financial-ledger.accounts.status') }}" aria-label="账户 {{ $row->account_short }} 状态">
@foreach ($accountStatusOptions as $statusCode => $statusLabel)
<option value="{{ $statusCode }}" @selected($row->account_status === $statusCode)>{{ $statusLabel }}</option>
@endforeach
</select>
@else
<span class="ledger-status-badge" data-status="{{ $row->account_status }}">{{ $accountStatusOptions[$row->account_status] ?? '正常' }}</span>
@endif
</td>
<td>{{ $row->region }}</td><td>{{ $row->account_no }}</td><td>{{ $row->bank_name }}</td><td>{{ $row->bank_city_branch }}</td>
<td>{{ $row->company_plan }}</td><td>{{ $row->account_last4 }}</td><td>{{ $row->single_limit }}</td><td>{{ $row->daily_limit }}</td><td>{{ $row->monthly_limit }}</td><td>{{ $row->yearly_limit }}</td><td class="num">{{ $money($row->current_balance) }}</td><td class="num">{{ $money($row->blocked_amount) }}</td><td class="num">{{ $money($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td>{{ $row->raise_target }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
</tr>
@empty
<tr><td colspan="24"><div class="ledger-empty">没有匹配的账户数据。</div></td></tr>
<tr><td colspan="25"><div class="ledger-empty">没有匹配的账户数据。</div></td></tr>
@endforelse
</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($ledgerAccounts->total()) }} 条 · 第 {{ $ledgerAccounts->currentPage() }} / {{ $ledgerAccounts->lastPage() }} 页</span><span>@if (!$ledgerAccounts->onFirstPage())<a href="{{ $ledgerAccounts->previousPageUrl() }}">上一页</a>@endif @if ($ledgerAccounts->hasMorePages())<a href="{{ $ledgerAccounts->nextPageUrl() }}">下一页</a>@endif</span></div>
</div>
<div class="ledger-pagination">
<span>共 {{ number_format($ledgerAccounts->total()) }} 条 · 每页 100 条</span>
<div class="ledger-pagination__controls">
<nav class="ledger-page-box" aria-label="账户总表分页">
@if ($ledgerAccounts->onFirstPage())
<span class="ledger-page-box__arrow is-disabled" aria-disabled="true" title="已经是第一页"><span class="ledger-page-box__triangle is-previous" aria-hidden="true"></span></span>
@else
<a class="ledger-page-box__arrow" href="{{ $ledgerAccounts->previousPageUrl() }}" aria-label="上一页" title="上一页"><span class="ledger-page-box__triangle is-previous" aria-hidden="true"></span></a>
@endif
<span class="ledger-page-box__current">{{ $ledgerAccounts->currentPage() }} / {{ $ledgerAccounts->lastPage() }}</span>
@if ($ledgerAccounts->hasMorePages())
<a class="ledger-page-box__arrow" href="{{ $ledgerAccounts->nextPageUrl() }}" aria-label="下一页" title="下一页"><span class="ledger-page-box__triangle is-next" aria-hidden="true"></span></a>
@else
<span class="ledger-page-box__arrow is-disabled" aria-disabled="true" title="已经是最后一页"><span class="ledger-page-box__triangle is-next" aria-hidden="true"></span></span>
@endif
</nav>
</div>
</div>
</section>
@ -204,7 +329,19 @@
<div class="ledger-field" style="min-width:auto"><label>&nbsp;</label><label style="display:flex;align-items:center;gap:.4rem;height:2.15rem"><input type="checkbox" name="mapping_unmapped" value="1" @checked(request()->boolean('mapping_unmapped'))> 只看未映射</label></div>
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('mapping') }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
</form>
<div class="ledger-note">{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}</div>
<div class="ledger-note ledger-mapping-bulk">
<span>{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}</span>
@if ($canEdit)
<div class="ledger-mapping-bulk-actions">
<button class="ledger-btn" type="button" data-mapping-bulk data-mapping-kind="shop" data-preview-url="{{ route('business.financial-ledger.mapping.shop.preview') }}" data-apply-url="{{ route('business.financial-ledger.mapping.shop.apply') }}">
<span class="material-icons-outlined" aria-hidden="true">hub</span>按店铺台账批量关联
</button>
<button class="ledger-btn" type="button" data-mapping-bulk data-mapping-kind="payable" data-preview-url="{{ route('business.financial-ledger.mapping.payable.preview') }}" data-apply-url="{{ route('business.financial-ledger.mapping.payable.apply') }}">
<span class="material-icons-outlined" aria-hidden="true">account_balance</span>按账户总表匹配供应商
</button>
</div>
@endif
</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:64rem"><thead><tr><th>来源</th><th>原始值</th><th class="num">笔数</th><th class="num">收入合计</th><th class="num">支出合计</th><th>映射账户简称</th><th>状态</th></tr></thead>
<tbody>@forelse (($mapping['rows'] ?? collect()) as $row)<tr data-mapping-row data-id="{{ $row->id }}"><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td>{{ $row->source_value }}</td><td class="num">{{ number_format((int) $row->txn_count) }}</td><td class="num">{{ $money($row->income_sum) }}</td><td class="num">{{ $money($row->expense_sum) }}</td><td>@if ($canEdit)<input class="ledger-map-input" list="ledger-account-list" value="{{ $row->account_short }}" placeholder="选择账户简称" data-mapping-input data-id="{{ $row->id }}" data-url="{{ route('business.financial-ledger.mapping.update', ['id' => $row->id]) }}">@else{{ $row->account_short ?: '未匹配' }}@endif</td><td data-mapping-state class="{{ $row->account_short ? 'success' : 'danger' }}">{{ $row->account_short ? '已映射' : '未匹配' }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的映射记录。</div></td></tr>@endforelse</tbody>
@ -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();
})();
</script>

View File

@ -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');

View File

@ -0,0 +1,309 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerAccountStatusTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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('<th>账户简称</th><th>账户类型</th><th class="ledger-status-column">账户状态</th>', 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');
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerAccountTemplateTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerPayableMappingBulkTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerShopMappingBulkTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}