diff --git a/app/Http/Controllers/Business/AccountMappingActions.php b/app/Http/Controllers/Business/AccountMappingActions.php index ab56a220..045f5d6d 100644 --- a/app/Http/Controllers/Business/AccountMappingActions.php +++ b/app/Http/Controllers/Business/AccountMappingActions.php @@ -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> + */ + 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> $events + * @return array + */ + 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 $event + * @param array $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 $event + * @param array|null $before + * @param array|null $after + * @return array + */ + 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|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 $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; diff --git a/resources/views/business/pages/financial-rules/account-mapping.blade.php b/resources/views/business/pages/financial-rules/account-mapping.blade.php index 98536be4..d51e0c75 100644 --- a/resources/views/business/pages/financial-rules/account-mapping.blade.php +++ b/resources/views/business/pages/financial-rules/account-mapping.blade.php @@ -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 @@ + @@ -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 '
' + esc(parts[0]) + '
'; } + return '
  • ' + parts.map(esc).join('
  • ') + '
'; + } + function histRow(event) { + return '' + + '' + esc(event.at || '') + '' + + '' + + esc(event.action_label || event.action || '') + '' + + '' + esc(event.platform || '—') + + '
' + esc(event.item || '') + + (event.memo ? '(备注 ' + esc(event.memo) + ')' : '') + '
' + + '' + histChangeHtml(event) + '' + + '' + esc(event.actor || '') + '' + + '' + histRestoreCell(event) + '' + + ''; + } + // 只有「删除」和「修改」能恢复:删除 → 建回这一行,修改 → 还原成改前内容 + function histRestoreCell(event) { + if (event.action !== 'delete' && event.action !== 'update') { + return '—'; + } + return ''; + } + 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('') + : '没有符合条件的记录。'; + 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 '' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '
时间操作平台 / 账单项目变更内容操作人操作
'; + } + 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 = '' + + '操作记录读取失败:' + esc(err && err.message ? err.message : err) + ''; + } + }); + } + + 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); diff --git a/routes/admin.php b/routes/admin.php index 73bfe7e1..90441511 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Business/AccountMappingPageTest.php b/tests/Feature/Business/AccountMappingPageTest.php index 8608a44b..55d8d093 100644 --- a/tests/Feature/Business/AccountMappingPageTest.php +++ b/tests/Feature/Business/AccountMappingPageTest.php @@ -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 {