'新增', '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, private readonly AccountMappingCoa $coa, private readonly AccountMappingWorkbookImporter $importer, ) { $this->middleware('auth'); $this->middleware('company.identify'); } /** 全量规则 + 校验结果 + 筛选项 */ public function index(): JsonResponse { return response()->json($this->payload()); } public function store(Request $request): JsonResponse { $values = $this->payloadValues($request); if (trim((string) ($values['platform'] ?? '')) === '' || trim((string) ($values['item'] ?? '')) === '') { throw ValidationException::withMessages(['platform' => '平台和账单项目不能为空。']); } $this->store->create($values, $this->userId()); $hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]); return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload()); } public function update(Request $request, int $id): JsonResponse { $before = $this->store->find($id); if ($before === null) { throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']); } $values = $this->payloadValues($request); $this->store->update($id, $values, $this->userId()); $hint = $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($values['platform'] ?? '')]); return response()->json(['message' => '已保存。' . $hint] + $this->payload()); } public function destroy(int $id): JsonResponse { $before = $this->store->find($id); if (! $this->store->destroy($id, $this->userId())) { throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']); } $hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]); return response()->json(['message' => '已删除 1 条规则。' . $hint] + $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()); $hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]); return response()->json([ 'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint, ] + $this->payload()); } if ($action === 'update') { $current = $this->store->find($ruleId); if ($current === null) { throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']); } $this->store->update($ruleId, $before, $this->userId()); $hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]); return response()->json([ 'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint, ] + $this->payload()); } throw ValidationException::withMessages(['history' => '只有「删除」和「修改」的记录可以恢复。']); } /** * 导入预览:只解析、不落盘。 * * 单独一条路由、且不挂 throttle:import —— 因为平台对「导入」限的是 1 次/分钟, * 而页面上「预览 → 确认导入」天然要发两次请求,预览走限流必然 429。 */ public function preview(Request $request): JsonResponse { return response()->json($this->previewPayload($this->workbookFile($request))); } /** * 导入落盘。带 ?dry_run=1 时行为同 preview(保留兼容)。 */ public function import(Request $request): JsonResponse { $file = $this->workbookFile($request); if ($request->boolean('dry_run')) { return response()->json($this->previewPayload($file)); } $parsed = $this->importer->parse($file); $result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId()); $message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。"; $platforms = array_values(array_unique(array_map( static fn (array $row): string => (string) ($row['platform'] ?? ''), $parsed['rows'] ))); $message .= $this->dispatchResync($platforms); return response()->json(['message' => $message, 'result' => $result] + $this->payload()); } /** * 取上传的工作簿。 * * 这里**故意不用** `mimes:xlsx,xls,csv`:那个规则靠 libmagic 嗅探真实类型, * 而只有两三行的 CSV 会被猜成 `text/plain`、扩展名算成 `txt`,于是合法文件被拒 * (实测 2 行 231B 的 csv 报「必须是 xlsx, xls, csv 的文件」)。按扩展名判就稳。 */ protected function workbookFile(Request $request) { $request->validate([ 'import' => ['required', 'file', 'max:10240'], ], [ 'import.required' => '请先选择要导入的文件。', 'import.file' => '请选择要导入的文件。', 'import.max' => '文件不能超过 10MB。', ]); $file = $request->file('import'); $extension = mb_strtolower((string) $file->getClientOriginalExtension()); if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) { throw ValidationException::withMessages([ 'import' => '只支持 xlsx、xls、csv 三种格式,当前是「' . $extension . '」。', ]); } return $file; } /** * @param \Illuminate\Http\UploadedFile $file * @return array */ protected function previewPayload($file): array { $parsed = $this->importer->parse($file); $rows = $parsed['rows']; $existing = []; foreach ($this->store->all() as $rule) { $existing[$this->store->keyOf($rule)] = true; } $fresh = []; $duplicated = []; foreach ($rows as $row) { $key = $this->store->keyOf($row); if (isset($existing[$key])) { $duplicated[$key] = ($duplicated[$key] ?? 0) + 1; } else { $fresh[$key] = true; $existing[$key] = true; } } return [ 'dry_run' => true, 'sheet' => $parsed['sheet'], 'total' => count($rows), 'can_create' => count($fresh), 'can_overwrite' => count($duplicated), 'duplicate_keys' => array_slice(array_keys($duplicated), 0, 20), 'sample' => array_slice($rows, 0, 6), ]; } /** 恢复成初始 112 条 */ public function reset(): JsonResponse { $total = $this->store->reset($this->userId()); return response()->json(['message' => "已恢复为初始 {$total} 条规则。" . $this->dispatchResyncAll()] + $this->payload()); } /** 账套科目(一级 + 各一级下的明细),供下拉使用 */ public function accounts(): JsonResponse { $level1 = $this->coa->level1(); $children = []; foreach ($level1 as $account) { $children[$account['code']] = $this->coa->descendants($account['code']); } return response()->json(['level1' => $level1, 'children' => $children]); } /** 按当前表导出 CSV(Excel 可直接打开) */ public function export(): StreamedResponse { $rules = $this->store->all(); return response()->streamDownload(function () use ($rules): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new RuntimeException('无法创建导出文件。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_values(AccountMappingStore::LABELS)); foreach ($rules as $rule) { $row = []; foreach (array_keys(AccountMappingStore::LABELS) as $field) { $value = (string) ($rule[$field] ?? ''); // 商品名称关键词留空时导出成「-」,与页面一致(导回来会按空处理) if ($field === 'goods' && trim($value) === '') { $value = '-'; } $row[] = $this->csvText($value); } fputcsv($csv, $row); } fclose($csv); }, '科目映射表_' . $this->store->platformNow()->format('Ymd_His') . '.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } /** 导入模板(表头 + 两条示例,并且这一份可以直接改完再导回来) */ public function template(): StreamedResponse { $samples = [ ['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'], ['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'], ]; return response()->streamDownload(function () use ($samples): void { $csv = fopen('php://output', 'w'); if ($csv === false) { throw new RuntimeException('无法创建模板文件。'); } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); fputcsv($csv, array_values(AccountMappingStore::LABELS)); foreach ($samples as $sample) { fputcsv($csv, $sample); } fclose($csv); }, '科目映射表_导入模板.csv', [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', ]); } /** @return array */ private function payload(): array { $rules = $this->store->all(); $validated = $this->validator->validate($rules); $rows = []; foreach ($rules as $rule) { $rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []]; } return [ 'rules' => $rows, 'summary' => $validated['summary'], 'options' => $this->store->options(), 'events' => $this->store->events(20), ]; } /** @return array */ private function payloadValues(Request $request): array { $input = (array) $request->input('values', $request->all()); return $this->store->normalize($input); } private function userId(): ?int { return user_id() ? (int) user_id() : null; } /** * 保存映射后把受影响的平台排进后台重跑队列。 * * 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的, * 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。 * * @param list $platforms */ private function dispatchResync(array $platforms): string { try { $result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms); } catch (\Throwable $exception) { report($exception); return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。'; } return $result['jobs'] > 0 ? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。" : ''; } private function dispatchResyncAll(): string { try { $result = app(AccountMappingResyncDispatcher::class)->dispatchAll(); } catch (\Throwable $exception) { report($exception); return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。'; } return $result['jobs'] > 0 ? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。" : ''; } /** * 原始操作记录 + 页面/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; return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value; } }