科目映射表加操作记录:查看新增/修改/删除/导入历史,支持筛选、导出与恢复

This commit is contained in:
yueyang 2026-09-29 15:56:27 +08:00
parent 2370c50bb5
commit 55e92cc95b
4 changed files with 650 additions and 1 deletions

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Business;
use App\Models\Auth\User;
use App\Traits\Companies;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -24,6 +25,30 @@ class AccountMappingActions extends Controller
{
use Companies;
/** 操作记录保留并展示的条数 */
private const HISTORY_LIMIT = 500;
private const HISTORY_ACTION_LABELS = [
'create' => '新增',
'update' => '修改',
'delete' => '删除',
'import' => '导入',
'reset' => '重置',
];
private const HISTORY_FIELD_LABELS = [
'platform' => '平台',
'item' => '账单项目',
'memo' => '财务备注',
'invoice' => '票据口径',
'category' => '大类',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
'cSub' => '贷方二级',
'basis' => '方向基准',
];
public function __construct(
private readonly AccountMappingStore $store,
private readonly AccountMappingValidator $validator,
@ -72,6 +97,111 @@ class AccountMappingActions extends Controller
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
}
/**
* 操作记录:最近 500 条(新增 / 修改 / 删除 / 导入 / 重置)。
*
* 每条含改动前、改动后的完整规则内容,所以能查出「谁在什么时候把哪个字段从什么改成了什么」,
* 也能据此还原被删掉的行。操作人只返回名称,不返回账号等敏感字段。
*/
public function history(): JsonResponse
{
$events = $this->historyEvents();
return response()->json([
'events' => $events,
'total' => count($events),
'limit' => self::HISTORY_LIMIT,
]);
}
/**
* 操作记录导出为 CSV,筛选口径与页面上的「操作记录」一致。
*/
public function historyExport(Request $request): StreamedResponse
{
$action = trim((string) $request->query('action', ''));
$keyword = trim((string) $request->query('keyword', ''));
$rows = array_values(array_filter(
$this->historyEvents(),
fn (array $row): bool => $this->historyMatches($row, $action, $keyword)
));
return response()->streamDownload(function () use ($rows): void {
$handle = fopen('php://output', 'w');
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, ['时间', '操作', '平台', '账单项目', '财务备注', '变更内容', '操作人']);
foreach ($rows as $row) {
fputcsv($handle, [
$row['at'], $row['action_label'], $row['platform'], $row['item'], $row['memo'],
implode(';', $row['changes']), $row['actor'],
]);
}
fclose($handle);
}, '科目映射表_操作记录.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 按操作记录恢复:被删除的规则重新建回来,被改过的规则恢复成改前内容。
*
* 前端传的是「记录在列表里的位置 + 时间 + 操作 + 规则号」,三者都对得上才动手,
* 避免这期间有人新增了记录、位置发生偏移时恢复错行。
*/
public function historyRestore(Request $request): JsonResponse
{
$index = (int) $request->input('index', -1);
$at = trim((string) $request->input('at', ''));
$action = trim((string) $request->input('action', ''));
$ruleId = (int) $request->input('id', 0);
$event = $this->store->events(self::HISTORY_LIMIT)[$index] ?? null;
if ($event === null
|| (string) ($event['at'] ?? '') !== $at
|| (string) ($event['action'] ?? '') !== $action
|| (int) ($event['id'] ?? 0) !== $ruleId) {
throw ValidationException::withMessages(['history' => '操作记录已经变化,请关掉面板重新打开再试。']);
}
$before = is_array($event['before'] ?? null) ? $event['before'] : null;
if ($before === null) {
throw ValidationException::withMessages(['history' => '这条记录没有可恢复的内容。']);
}
if ($action === 'delete') {
foreach ($this->store->all() as $rule) {
if ($this->store->keyOf($rule) === $this->store->keyOf($before)) {
throw ValidationException::withMessages([
'history' => '这条规则已经在表里了(记录 #' . (int) $rule['id'] . '),不用恢复。',
]);
}
}
$restored = $this->store->create($before, $this->userId());
return response()->json([
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。',
] + $this->payload());
}
if ($action === 'update') {
if ($this->store->find($ruleId) === null) {
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
}
$this->store->update($ruleId, $before, $this->userId());
return response()->json([
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。',
] + $this->payload());
}
throw ValidationException::withMessages(['history' => '只有「删除」和「修改」的记录可以恢复。']);
}
/**
* 导入预览:只解析、不落盘。
*
@ -271,6 +401,169 @@ class AccountMappingActions extends Controller
return user_id() ? (int) user_id() : null;
}
/**
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
*
* @return array<int,array<string,mixed>>
*/
private function historyEvents(): array
{
$events = $this->store->events(self::HISTORY_LIMIT);
$names = $this->historyUserNames($events);
$rows = [];
foreach ($events as $event) {
$before = is_array($event['before'] ?? null) ? $event['before'] : null;
$after = is_array($event['after'] ?? null) ? $event['after'] : null;
$rule = $before ?? $after ?? [];
$action = (string) ($event['action'] ?? '');
$rows[] = $event + [
'action_label' => self::HISTORY_ACTION_LABELS[$action] ?? $action,
'platform' => trim((string) ($rule['platform'] ?? '')),
'item' => trim((string) ($rule['item'] ?? '')),
'memo' => trim((string) ($rule['memo'] ?? '')),
'changes' => $this->historyChangeParts($event, $before, $after),
'actor' => $this->historyActor($event, $names),
];
}
return $rows;
}
/**
* @param array<int,array<string,mixed>> $events
* @return array<int,string>
*/
private function historyUserNames(array $events): array
{
$ids = [];
foreach ($events as $event) {
$userId = (int) ($event['user_id'] ?? 0);
if ($userId > 0) {
$ids[$userId] = true;
}
}
if ($ids === []) {
return [];
}
return User::query()->whereIn('id', array_keys($ids))->pluck('name', 'id')->all();
}
/**
* @param array<string,mixed> $event
* @param array<int,string> $names
*/
private function historyActor(array $event, array $names): string
{
$userId = (int) ($event['user_id'] ?? 0);
if ($userId <= 0) {
return '脚本 / 系统';
}
return $names[$userId] ?? ('用户 #' . $userId);
}
/**
* 变更内容:修改给出「字段:改前 → 改后」,删除/新增给出科目对摘要,导入/重置给出条数。
*
* @param array<string,mixed> $event
* @param array<string,string>|null $before
* @param array<string,string>|null $after
* @return array<int,string>
*/
private function historyChangeParts(array $event, ?array $before, ?array $after): array
{
$action = (string) ($event['action'] ?? '');
if ($action === 'update') {
$parts = [];
foreach (self::HISTORY_FIELD_LABELS as $field => $label) {
$old = trim((string) ($before[$field] ?? ''));
$new = trim((string) ($after[$field] ?? ''));
if ($old === $new) {
continue;
}
$parts[] = $label . ':' . ($old === '' ? '(空)' : $old) . ' → ' . ($new === '' ? '(空)' : $new);
}
return $parts === [] ? ['未识别到字段变化'] : $parts;
}
if ($action === 'delete') {
return ['删除前:' . $this->historyRuleSummary($before)];
}
if ($action === 'create') {
return ['新增:' . $this->historyRuleSummary($after)];
}
if ($action === 'import') {
$parts = [];
foreach (['created' => '新增', 'updated' => '更新', 'skipped' => '跳过'] as $key => $label) {
if (isset($event[$key])) {
$parts[] = $label . ' ' . (int) $event[$key];
}
}
return ['导入:' . ($parts === [] ? '—' : implode(' · ', $parts))];
}
if ($action === 'reset') {
return ['重置为初始规则' . (isset($event['total']) ? '(' . (int) $event['total'] . ' 条)' : '')];
}
return [$this->historyRuleSummary($before ?? $after)];
}
/** @param array<string,string>|null $rule */
private function historyRuleSummary(?array $rule): string
{
$rule ??= [];
$side = static function (string $acctKey, string $subKey) use ($rule): string {
$acct = trim((string) ($rule[$acctKey] ?? ''));
$sub = trim((string) ($rule[$subKey] ?? ''));
if ($acct === '' && $sub === '') {
return '—';
}
if ($sub === '' || $sub === $acct) {
return $acct !== '' ? $acct : $sub;
}
return $acct . ' / ' . $sub;
};
return '借 ' . $side('dAcct', 'dSub') . ' | 贷 ' . $side('cAcct', 'cSub')
. ' | ' . (trim((string) ($rule['category'] ?? '')) ?: '—')
. ' · ' . (trim((string) ($rule['basis'] ?? '')) ?: '—')
. ' · 票据 ' . (trim((string) ($rule['invoice'] ?? '')) ?: '—');
}
/** @param array<string,mixed> $row */
private function historyMatches(array $row, string $action, string $keyword): bool
{
if ($action !== '' && (string) ($row['action'] ?? '') !== $action) {
return false;
}
if ($keyword === '') {
return true;
}
$haystack = mb_strtolower(implode(' ', [
$row['platform'] ?? '',
$row['item'] ?? '',
$row['memo'] ?? '',
$row['actor'] ?? '',
implode(' ', (array) ($row['changes'] ?? [])),
]));
return str_contains($haystack, mb_strtolower($keyword));
}
private function csvText(mixed $value): string
{
$value = (string) $value;

View File

@ -201,6 +201,8 @@
border: 1px solid #e4dccd;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22);
}
/* 操作记录表头多、内容长,单开一个更宽的档位 */
.am-modal__box--wide { width: min(1120px, 100%); }
.am-modal__head {
padding: 1rem 1.25rem;
border-bottom: 1px solid #eee7da;
@ -212,6 +214,34 @@
.am-modal__body { padding: 1.25rem; font-size: 0.8125rem; line-height: 1.8; color: #3d3a35; }
.am-modal__foot { padding: 1rem 1.25rem; border-top: 1px solid #eee7da; display: flex; gap: 0.5rem; justify-content: flex-end; }
.am-modal__close { border: 0; background: transparent; font-size: 1.1rem; cursor: pointer; color: #8a8377; }
/* 操作记录 */
.am-hist-tools { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; }
.am-hist-tools select,
.am-hist-tools input {
height: 2rem; border: 1px solid #ddd5c7; border-radius: 8px; background: #fff;
color: #3d3a35; padding: 0 0.5rem; font-size: 0.8125rem;
}
.am-hist-tools input { min-width: 12rem; }
.am-hist-scroll { max-height: 58vh; overflow: auto; border: 1px solid #eee7da; border-radius: 8px; }
table.am-hist-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.am-hist-table th {
position: sticky; top: 0; z-index: 1; background: #f7f3ea; text-align: left;
padding: 0.5rem 0.65rem; border-bottom: 1px solid #eee7da; color: #5c5546;
font-weight: 600; white-space: nowrap;
}
.am-hist-table td { padding: 0.55rem 0.65rem; border-bottom: 1px solid #f3eee4; color: #3d3a35; vertical-align: top; }
.am-hist-table tr:last-child td { border-bottom: 0; }
.am-hist-at { white-space: nowrap; color: #6b6455; }
.am-hist-meta { color: #8a8377; font-size: 0.75rem; }
.am-hist-tag { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
.am-hist-tag--create { background: #eaf3de; color: #3b6d11; }
.am-hist-tag--update { background: #eaf0f7; color: #3f6485; }
.am-hist-tag--delete { background: #fdecec; color: #a32d2d; }
.am-hist-tag--import { background: #fbf3e2; color: #8a6414; }
.am-hist-tag--reset { background: #f2efe7; color: #6b6455; }
.am-hist-changes { margin: 0; padding-left: 1rem; }
.am-hist-changes li { margin: 0.1rem 0; }
.am-kv { display: grid; grid-template-columns: 7rem 1fr; gap: 0.4rem 0.75rem; margin: 0.75rem 0; }
.am-kv__k { color: #8a8377; }
.am-preview-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; margin-top: 0.5rem; }
@ -324,6 +354,7 @@
<button type="button" class="am-btn" id="amImportBtn">导入</button>
<button type="button" class="am-btn" id="amExportBtn">导出 CSV</button>
<button type="button" class="am-btn" id="amTemplateBtn">导入模板</button>
<button type="button" class="am-btn" id="amHistoryBtn">操作记录</button>
<button type="button" class="am-btn" id="amResetFilter">重置筛选</button>
<span id="amCount" style="font-size:0.75rem;color:#8a8377;"></span>
</div>
@ -889,7 +920,11 @@
: String(footButtons || '');
modal.hidden = false;
}
function closeModal() { modal.hidden = true; }
function closeModal() {
modal.hidden = true;
var box = modal.querySelector('.am-modal__box');
if (box) { box.classList.remove('am-modal__box--wide'); }
}
document.getElementById('amModalClose').addEventListener('click', closeModal);
modal.addEventListener('click', function (ev) { if (ev.target === modal) { closeModal(); } });
@ -897,6 +932,157 @@
document.getElementById('amExportBtn').addEventListener('click', function () { window.location.href = ENDPOINT + '/export'; });
document.getElementById('amTemplateBtn').addEventListener('click', function () { window.location.href = ENDPOINT + '/template'; });
/* ---------- 操作记录(变更内容与操作人由服务端算好,前端只渲染) ---------- */
var HIST = { events: [] };
function histChangeHtml(event) {
var parts = Array.isArray(event.changes) && event.changes.length ? event.changes : ['—'];
if (parts.length === 1) { return '<div>' + esc(parts[0]) + '</div>'; }
return '<ul class="am-hist-changes"><li>' + parts.map(esc).join('</li><li>') + '</li></ul>';
}
function histRow(event) {
return '<tr>'
+ '<td class="am-hist-at">' + esc(event.at || '') + '</td>'
+ '<td><span class="am-hist-tag am-hist-tag--' + esc(event.action || '') + '">'
+ esc(event.action_label || event.action || '') + '</span></td>'
+ '<td>' + esc(event.platform || '—')
+ '<div class="am-hist-meta">' + esc(event.item || '')
+ (event.memo ? '(备注 ' + esc(event.memo) + ')' : '') + '</div></td>'
+ '<td>' + histChangeHtml(event) + '</td>'
+ '<td>' + esc(event.actor || '') + '</td>'
+ '<td>' + histRestoreCell(event) + '</td>'
+ '</tr>';
}
// 只有「删除」和「修改」能恢复:删除 → 建回这一行,修改 → 还原成改前内容
function histRestoreCell(event) {
if (event.action !== 'delete' && event.action !== 'update') {
return '<span class="am-hist-meta">—</span>';
}
return '<button type="button" class="am-btn-mini am-btn-mini--primary" data-hist-restore="1"'
+ ' data-index="' + esc(String(event.hist_index)) + '"'
+ ' data-at="' + esc(event.at || '') + '"'
+ ' data-action="' + esc(event.action) + '"'
+ ' data-rule-id="' + esc(String(event.id || '')) + '">恢复</button>';
}
function histRestore(btn) {
var index = Number(btn.getAttribute('data-index'));
var event = HIST.events[index] || {};
var what = event.action === 'delete'
? '恢复被删除的「' + (event.platform || '') + ' · ' + (event.item || '') + '」?'
: '把「' + (event.platform || '') + ' · ' + (event.item || '') + '」还原成这次修改前的内容?\n\n'
+ (Array.isArray(event.changes) ? event.changes.join('\n') : '');
if (!window.confirm(what)) { return; }
btn.disabled = true;
api('POST', '/history/restore', {
index: index,
at: event.at || '',
action: event.action || '',
id: Number(event.id || 0)
}).then(function (data) {
applyState(data);
toast(data.message || '已恢复。');
return histLoad();
}).catch(function (e) {
btn.disabled = false;
toast(e.message, 'err');
});
}
function histFilters() {
var actionEl = document.getElementById('amHistAction');
var kwEl = document.getElementById('amHistKeyword');
return {
action: actionEl ? actionEl.value : '',
keyword: kwEl ? kwEl.value.trim() : ''
};
}
function histFiltered() {
var f = histFilters();
var needle = f.keyword.toLowerCase();
return HIST.events.filter(function (event) {
if (f.action && String(event.action || '') !== f.action) { return false; }
if (!needle) { return true; }
var hay = [event.platform, event.item, event.memo, event.actor,
(Array.isArray(event.changes) ? event.changes.join(' ') : '')].join(' ').toLowerCase();
return hay.indexOf(needle) >= 0;
});
}
function histRenderTable() {
var tbody = document.getElementById('amHistBody');
if (!tbody) { return; }
var list = histFiltered();
tbody.innerHTML = list.length
? list.map(histRow).join('')
: '<tr><td colspan="6" class="am-hist-meta" style="padding:1rem;">没有符合条件的记录。</td></tr>';
var meta = document.getElementById('amHistMeta');
if (meta) {
meta.textContent = '显示 ' + list.length + ' / ' + HIST.events.length + ' 条'
+ (HIST.events.length >= 500 ? '(只保留最近 500 条)' : '');
}
}
function histExport() {
var f = histFilters();
window.location.href = ENDPOINT + '/history/export?action=' + encodeURIComponent(f.action)
+ '&keyword=' + encodeURIComponent(f.keyword);
}
function histBodyHtml() {
return ''
+ '<div class="am-hist-tools">'
+ '<select id="amHistAction"><option value="">全部操作</option>'
+ '<option value="create">新增</option><option value="update">修改</option>'
+ '<option value="delete">删除</option><option value="import">导入</option>'
+ '<option value="reset">重置</option></select>'
+ '<input type="search" id="amHistKeyword" placeholder="搜平台 / 账单项目 / 操作人">'
+ '<button type="button" class="am-btn" id="amHistExport">导出记录 CSV</button>'
+ '<span class="am-hist-meta" id="amHistMeta"></span>'
+ '</div>'
+ '<div class="am-hist-scroll"><table class="am-hist-table">'
+ '<thead><tr><th style="width:9.5rem;">时间</th><th style="width:4.5rem;">操作</th>'
+ '<th style="width:13rem;">平台 / 账单项目</th><th>变更内容</th>'
+ '<th style="width:7.5rem;">操作人</th><th style="width:5.5rem;">操作</th></tr></thead>'
+ '<tbody id="amHistBody"></tbody></table></div>';
}
function histLoad() {
return fetch(ENDPOINT + '/history', { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) { throw new Error(data.message || ('读取失败(HTTP ' + res.status + ')')); }
return data;
});
})
.then(function (data) {
// 记下位置:恢复时要带着它回服务端核对,避免记录变了恢复错行
HIST.events = (Array.isArray(data.events) ? data.events : []).map(function (event, i) {
event.hist_index = i;
return event;
});
histRenderTable();
})
.catch(function (err) {
var tbody = document.getElementById('amHistBody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="6" class="am-hist-meta" style="padding:1rem;color:#a32d2d;">'
+ '操作记录读取失败:' + esc(err && err.message ? err.message : err) + '</td></tr>';
}
});
}
function openHistory() {
var box = modal.querySelector('.am-modal__box');
if (box) { box.classList.add('am-modal__box--wide'); }
openModal('科目映射表 · 操作记录', histBodyHtml(), '');
document.getElementById('amHistAction').addEventListener('change', histRenderTable);
document.getElementById('amHistKeyword').addEventListener('input', histRenderTable);
document.getElementById('amHistExport').addEventListener('click', histExport);
document.getElementById('amHistBody').addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-hist-restore]');
if (btn) { histRestore(btn); }
});
histLoad();
}
document.getElementById('amHistoryBtn').addEventListener('click', openHistory);
/* ---------- 筛选 ---------- */
[kw, fPlatform, fCategory, fBasis, fOnly].forEach(function (el) {
el.addEventListener('input', render);

View File

@ -213,6 +213,11 @@ Route::prefix('workspace/financial-rules/account-mapping/actions')
Route::patch('rules/{id}', [AccountMappingActions::class, 'update'])->whereNumber('id')->name('rules.update');
Route::delete('rules/{id}', [AccountMappingActions::class, 'destroy'])->whereNumber('id')->name('rules.destroy');
Route::get('accounts', [AccountMappingActions::class, 'accounts'])->name('accounts');
// 操作记录:新增 / 修改 / 删除 / 导入 / 重置 的历史,供页面「操作记录」查看
Route::get('history', [AccountMappingActions::class, 'history'])->name('history');
Route::get('history/export', [AccountMappingActions::class, 'historyExport'])->name('history.export');
// 恢复:按记录把被删的行建回来 / 把改过的行还原
Route::post('history/restore', [AccountMappingActions::class, 'historyRestore'])->name('history.restore');
// 预览不落盘,所以不挂 throttle:import(限额 1 次/分钟,会卡住「预览→确认」两次请求)
Route::post('preview', [AccountMappingActions::class, 'preview'])->name('preview');
Route::post('import', [AccountMappingActions::class, 'import'])->middleware('import')->name('import');

View File

@ -149,6 +149,171 @@ class AccountMappingPageTest extends FeatureTestCase
$this->assertNull(app(AccountMappingStore::class)->find($id));
}
/**
* 造一组「新增 → 修改 → 删除」的操作记录,返回最后那条规则的 id。
*/
private function seedHistoryRows(bool $withDelete = true): int
{
$values = [
'platform' => '自测平台',
'item' => '自测账单项目',
'memo' => '自测备注',
'invoice' => '不开票',
'category' => '费用',
'dAcct' => '销售费用',
'dSub' => '运费',
'cAcct' => '其他货币资金',
'cSub' => '',
'basis' => '支出基准',
];
$created = $this->loginAs()->postJson($this->rulesUrl(), ['values' => $values])->assertOk();
$id = (int) collect($created->json('rules'))->firstWhere('platform', '自测平台')['id'];
$this->loginAs()->patchJson($this->rulesUrl($id), [
'values' => array_merge($values, ['memo' => '改过的备注']),
])->assertOk();
if ($withDelete) {
$this->loginAs()->deleteJson($this->rulesUrl($id))->assertOk();
}
return $id;
}
/** 操作记录要能看到新增/修改/删除,并带改动前后内容与操作人 */
public function test_history_endpoint_lists_changes_and_actor(): void
{
$this->grantAdminPermission();
$this->seedHistoryRows();
$history = $this->loginAs()->getJson($this->actionUrl('history'))->assertOk()->json();
$this->assertSame(3, $history['total']);
$this->assertSame(['delete', 'update', 'create'], array_column($history['events'], 'action'));
$this->assertSame(['删除', '修改', '新增'], array_column($history['events'], 'action_label'));
$latest = $history['events'][0];
$this->assertSame('自测平台', $latest['platform']);
$this->assertSame('自测账单项目', $latest['item']);
$this->assertSame($this->user->name, $latest['actor']);
$this->assertStringContainsString('删除前:借 销售费用 / 运费', $latest['changes'][0]);
// 修改要给出「字段:改前 → 改后」,做账的人才能看出改了什么
$this->assertSame(['财务备注:自测备注 → 改过的备注'], $history['events'][1]['changes']);
$this->assertStringContainsString('新增:借 销售费用 / 运费', $history['events'][2]['changes'][0]);
}
/** 操作记录导出 CSV,且按操作类型筛选(口径与页面一致) */
public function test_history_export_csv_can_filter_by_action(): void
{
$this->grantAdminPermission();
$this->seedHistoryRows();
$all = $this->loginAs()->get($this->actionUrl('history') . '/export')->streamedContent();
$this->assertStringContainsString('时间,操作,平台,账单项目,财务备注,变更内容,操作人', $all);
$this->assertSame(4, substr_count($all, "\n"));
$deleted = $this->loginAs()->get($this->actionUrl('history') . '/export?action=delete')->streamedContent();
$this->assertStringContainsString('删除,自测平台,自测账单项目', $deleted);
// 只留删除那一条:修改的「改前 → 改后」与新增都不该出现
$this->assertStringNotContainsString('→', $deleted);
$this->assertStringNotContainsString('新增:', $deleted);
$this->assertSame(2, substr_count($deleted, "\n"));
}
/** 恢复:被删除的规则要能建回来;已经存在则挡住,不能变成两条 */
public function test_history_restore_recreates_deleted_rule(): void
{
$this->grantAdminPermission();
$this->seedHistoryRows();
$store = app(AccountMappingStore::class);
$this->assertCount(self::SEED_TOTAL, $store->all());
$event = $this->loginAs()->getJson($this->actionUrl('history'))->json('events')[0];
$this->assertSame('delete', $event['action']);
$payload = ['index' => 0, 'at' => $event['at'], 'action' => $event['action'], 'id' => $event['id']];
$this->loginAs()->postJson($this->actionUrl('history') . '/restore', $payload)
->assertOk()
->assertJsonPath('summary.total', self::SEED_TOTAL + 1);
$restored = collect($store->all())->firstWhere('item', '自测账单项目');
$this->assertNotNull($restored);
$this->assertSame('运费', $restored['dSub']);
$this->assertSame('改过的备注', $restored['memo']);
// 再点一次:表里已经有了,必须挡住
$rows = $this->loginAs()->getJson($this->actionUrl('history'))->json('events');
$index = null;
foreach ($rows as $i => $row) {
if ($row['at'] === $event['at'] && $row['action'] === $event['action'] && $row['id'] === $event['id']) {
$index = $i;
break;
}
}
$this->assertNotNull($index);
$this->withExceptionHandling();
$repeat = $this->loginAs()->postJson(
$this->actionUrl('history') . '/restore',
['index' => $index, 'at' => $event['at'], 'action' => $event['action'], 'id' => $event['id']]
);
$repeat->assertStatus(422);
$this->assertStringContainsString('已经在表里了', (string) $repeat->json('errors.history.0'));
}
/** 恢复:修改过的规则要能还原成改前内容 */
public function test_history_restore_reverts_an_update(): void
{
$this->grantAdminPermission();
$id = $this->seedHistoryRows(false);
$store = app(AccountMappingStore::class);
$this->assertSame('改过的备注', $store->find($id)['memo']);
$rows = $this->loginAs()->getJson($this->actionUrl('history'))->json('events');
$index = null;
foreach ($rows as $i => $row) {
if ($row['action'] === 'update' && $row['id'] === $id) {
$index = $i;
break;
}
}
$this->assertNotNull($index);
$event = $rows[$index];
$this->loginAs()->postJson($this->actionUrl('history') . '/restore', [
'index' => $index, 'at' => $event['at'], 'action' => $event['action'], 'id' => $event['id'],
])->assertOk();
$this->assertSame('自测备注', $store->find($id)['memo']);
}
/** 恢复:记录已经变化(位置对不上)时要拒绝,避免恢复错行 */
public function test_history_restore_rejects_stale_target(): void
{
$this->grantAdminPermission();
$this->seedHistoryRows();
$event = $this->loginAs()->getJson($this->actionUrl('history'))->json('events')[0];
$this->withExceptionHandling();
$response = $this->loginAs()->postJson($this->actionUrl('history') . '/restore', [
'index' => 0,
'at' => '2000-01-01 00:00:00',
'action' => $event['action'],
'id' => $event['id'],
]);
$response->assertStatus(422);
$this->assertStringContainsString('已经变化', (string) $response->json('errors.history.0'));
}
/** 账套里查不到的一级科目必须是「错误」,否则自动生成分录时会静默失败 */
public function test_validator_flags_account_missing_from_chart_of_accounts(): void
{