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()); return response()->json(['message' => '已新增 1 条规则。'] + $this->payload()); } public function update(Request $request, int $id): JsonResponse { if ($this->store->find($id) === null) { throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']); } $this->store->update($id, $this->payloadValues($request), $this->userId()); return response()->json(['message' => '已保存。'] + $this->payload()); } public function destroy(int $id): JsonResponse { if (! $this->store->destroy($id, $this->userId())) { throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']); } return response()->json(['message' => '已删除 1 条规则。'] + $this->payload()); } /** * 导入预览:只解析、不落盘。 * * 单独一条路由、且不挂 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']} 条。"; 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->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) { $row[] = $this->csvText($rule[$field] ?? ''); } fputcsv($csv, $row); } fclose($csv); }, '科目映射表_' . now()->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; } private function csvText(mixed $value): string { $value = (string) $value; return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value; } }