diff --git a/app/import/import-view.tsx b/app/import/import-view.tsx new file mode 100644 index 0000000..3096ee2 --- /dev/null +++ b/app/import/import-view.tsx @@ -0,0 +1,792 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { + AlertCircleIcon, + ArrowRightIcon, + CheckCircle2Icon, + FileUpIcon, + LandmarkIcon, + Table2Icon, + WalletIcon, +} from "lucide-react" + +import { importCcbaRowsAction, type CcbaParsedRow } from "@/lib/actions/import" +import { type ChannelWithAccountNames } from "@/lib/actions/channel" + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Spinner } from "@/components/ui/spinner" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" + +interface ImportViewProps { + channels: ChannelWithAccountNames[] +} + +interface ImportResult { + success: boolean + imported?: number + skipped?: number + unmatchedAccounts?: string[] + error?: string +} + +const CSV_HEADER = [ + "trans_date", + "post_date", + "description", + "ref_no", + "amt", + "ccy", + "account", + "fx_amt", + "fx_ccy", + "fx_rate", +] as const + +const UNMAPPED_PLACEHOLDER = "请选择目标渠道" +const SKIP_ACCOUNT_LABEL = "暂不导入该账户" +const SKIP_ACCOUNT_VALUE = "__skip__" + +function channelName(channel: ChannelWithAccountNames) { + if (channel.channelType === "PAYMENT_CARD") + return channel.issuerName || "支付卡" + if (channel.channelType === "E_WALLET") return channel.platform || "电子钱包" + return channel.channelType === "CASH" ? "现金渠道" : "转账渠道" +} + +function channelOptionLabel(channel: ChannelWithAccountNames) { + const base = + channel.desc?.trim() || channel.issuerName?.trim() || channelName(channel) + const digits = ( + channel.cardNumberSuffix || + channel.cardNumberFull || + "" + ).replace(/\D/g, "") + const suffix = digits.length >= 4 ? digits.slice(-4) : "" + return suffix ? `${base} •••• ${suffix}` : channelName(channel) +} + +function matchChannelId(account: string, channels: ChannelWithAccountNames[]) { + const digitsMatch = account.match(/\d{12,}/) + if (!digitsMatch) return "" + + const digits = digitsMatch[0] + const exact = channels.find( + (channel) => (channel.cardNumberFull || "").replace(/\D/g, "") === digits + ) + if (exact) return exact.id + + const suffix = digits.slice(-4) + const bySuffix = channels.find((channel) => { + const channelSuffix = (channel.cardNumberSuffix || "").replace(/\D/g, "") + return channelSuffix.length >= 4 && channelSuffix.slice(-4) === suffix + }) + return bySuffix?.id || "" +} + +function nullableValue(value?: string): string | null { + const trimmed = (value ?? "").trim() + return trimmed === "" ? null : trimmed +} + +function parseCsv(input: string): string[][] { + let text = input + if (text.charCodeAt(0) === 0xfeff) { + text = text.slice(1) + } + + const rows: string[][] = [] + let row: string[] = [] + let field = "" + let inQuotes = false + let index = 0 + + while (index < text.length) { + const char = text[index] + + if (inQuotes) { + if (char === '"') { + if (text[index + 1] === '"') { + field += '"' + index += 2 + continue + } + inQuotes = false + index += 1 + continue + } + field += char + index += 1 + continue + } + + if (char === '"') { + inQuotes = true + index += 1 + continue + } + + if (char === ",") { + row.push(field) + field = "" + index += 1 + continue + } + + if (char === "\r" || char === "\n") { + row.push(field) + rows.push(row) + row = [] + field = "" + if (char === "\r" && text[index + 1] === "\n") { + index += 2 + } else { + index += 1 + } + continue + } + + field += char + index += 1 + } + + if (field !== "" || row.length > 0) { + row.push(field) + rows.push(row) + } + + return rows.filter((cells) => cells.some((cell) => cell.trim() !== "")) +} + +function parseCcbaCsv(text: string): { + rows: CcbaParsedRow[] + error: string | null +} { + const table = parseCsv(text) + + if (table.length === 0) { + return { rows: [], error: "文件内容为空,请检查后重新导出。" } + } + + const header = table[0].map((cell) => cell.trim()) + const headerValid = + header.length === CSV_HEADER.length && + CSV_HEADER.every((name, index) => header[index] === name) + + if (!headerValid) { + return { + rows: [], + error: + "CSV 表头与中国建设银行(亚洲)信用卡月结单模板不一致,请使用银行原始导出的文件。", + } + } + + const rows: CcbaParsedRow[] = table + .slice(1) + .filter((cells) => cells.some((cell) => cell.trim() !== "")) + .map((cells) => ({ + transDate: (cells[0] ?? "").trim(), + postDate: (cells[1] ?? "").trim(), + description: (cells[2] ?? "").trim(), + refNo: (cells[3] ?? "").trim(), + amt: (cells[4] ?? "").trim(), + ccy: (cells[5] ?? "").trim(), + account: (cells[6] ?? "").trim(), + fxAmt: nullableValue(cells[7]), + fxCcy: nullableValue(cells[8]), + fxRate: nullableValue(cells[9]), + })) + + if (rows.length === 0) { + return { rows: [], error: "未解析到任何数据行,请确认文件包含交易记录。" } + } + + return { rows, error: null } +} + +export function ImportView({ channels }: ImportViewProps) { + const [activeTab, setActiveTab] = React.useState("upload") + const [fileName, setFileName] = React.useState("") + const [parsedRows, setParsedRows] = React.useState([]) + const [selections, setSelections] = React.useState< + Record + >({}) + const [fileError, setFileError] = React.useState(null) + const [isImporting, setIsImporting] = React.useState(false) + const [result, setResult] = React.useState(null) + const fileInputRef = React.useRef(null) + + const channelMap = React.useMemo( + () => new Map(channels.map((channel) => [channel.id, channel])), + [channels] + ) + + const uniqueAccounts = React.useMemo(() => { + const set = new Set() + for (const row of parsedRows) { + if (row.account) set.add(row.account) + } + return Array.from(set) + }, [parsedRows]) + + const mappedCount = uniqueAccounts.filter((account) => { + const value = selections[account] + return Boolean(value) && value !== SKIP_ACCOUNT_VALUE + }).length + const pendingCount = uniqueAccounts.filter( + (account) => selections[account] == null + ).length + const hasUnmappedAccount = pendingCount > 0 + const submitDisabled = + parsedRows.length === 0 || hasUnmappedAccount || isImporting + + const importableCount = parsedRows.filter((row) => { + const value = selections[row.account] + return Boolean(value) && value !== SKIP_ACCOUNT_VALUE + }).length + const skipRowCount = parsedRows.filter( + (row) => selections[row.account] === SKIP_ACCOUNT_VALUE + ).length + + const stats = [ + { label: "总行数", value: parsedRows.length }, + { label: "识别账户数", value: uniqueAccounts.length }, + { label: "已映射账户", value: mappedCount }, + { label: "待映射账户", value: pendingCount }, + ] + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + + setFileError(null) + setResult(null) + + try { + const text = await file.text() + const parsed = parseCcbaCsv(text) + + if (parsed.error) { + setFileName(file.name) + setParsedRows([]) + setSelections({}) + setFileError(parsed.error) + return + } + + const nextSelections: Record = {} + for (const row of parsed.rows) { + if (!row.account || row.account in nextSelections) continue + nextSelections[row.account] = + matchChannelId(row.account, channels) || null + } + + setFileName(file.name) + setParsedRows(parsed.rows) + setSelections(nextSelections) + } catch { + setFileName(file.name) + setParsedRows([]) + setSelections({}) + setFileError("文件读取失败,请重新选择文件。") + } + } + + const handleSelectionChange = (account: string, value: string | null) => { + setSelections((prev) => ({ ...prev, [account]: value })) + } + + const handleImport = async () => { + if (submitDisabled) return + + setIsImporting(true) + setResult(null) + + try { + const accountChannelMap: Record = {} + for (const account of uniqueAccounts) { + const selected = selections[account] + if (selected && selected !== SKIP_ACCOUNT_VALUE) { + accountChannelMap[account] = selected + } + } + + const rowsToImport = parsedRows.filter((row) => { + const selected = selections[row.account] + return Boolean(selected) && selected !== SKIP_ACCOUNT_VALUE + }) + + if (rowsToImport.length === 0) { + setResult({ + success: false, + error: "没有可导入的记录,请至少为一个账户选择目标渠道。", + }) + return + } + + const response = await importCcbaRowsAction( + rowsToImport, + accountChannelMap + ) + setResult(response) + } catch (error) { + setResult({ + success: false, + error: error instanceof Error ? error.message : "导入失败,请重试。", + }) + } finally { + setIsImporting(false) + } + } + + return ( +
+
+
+

+ 导入账单 +

+

+ 上传中国建设银行(亚洲)信用卡月结单,完成账户映射后批量导入到待清洗流水。 +

+
+
+ + {fileError && ( + + + 无法解析该文件 + {fileError} + + )} + + setActiveTab(String(value))} + className="gap-4" + > + + 上传账单 + 账户映射 + 数据预览 + + + + {parsedRows.length === 0 ? ( + + + + + + 尚未导入账单 + + 目前仅支持中国建设银行(亚洲)信用卡月结单 CSV。PDF 转 CSV + 步骤暂略,解析在浏览器本地完成,文件不会上传。 + + + + + + + + ) : ( + + + 解析概览 + + 已读取 {fileName},解析在浏览器本地完成。 + + + +
+ {stats.map((stat) => ( +
+ + {stat.label} + + + {stat.value} + +
+ ))} +
+ + + + + 重新选择 CSV 文件 + + + + 重新选择会清除当前解析结果与账户映射。 + + + + +
+ +
+
+
+ )} +
+ + + {parsedRows.length === 0 ? ( + + + + + + 尚未解析到账户 + + 请先在「上传账单」步骤选择并解析中国建设银行(亚洲)信用卡月结单 + CSV。 + + + + + + + ) : channels.length === 0 ? ( + + + + + + 暂无可用支付渠道 + + 账户映射需要至少一个已创建的支付渠道,请先创建后再继续。 + + + + + + + ) : ( + + + 账户映射 + + 将月结单中的账户对应到已创建的支付渠道,未匹配到的可手动选择,或选择暂不导入。 + + + + + {uniqueAccounts.map((account) => { + const value = selections[account] ?? null + const selectedChannel = value + ? (channelMap.get(value) ?? null) + : null + const isSkipped = value === SKIP_ACCOUNT_VALUE + const displayValue = + value == null + ? UNMAPPED_PLACEHOLDER + : isSkipped + ? SKIP_ACCOUNT_LABEL + : selectedChannel + ? channelOptionLabel(selectedChannel) + : UNMAPPED_PLACEHOLDER + + return ( + + + + {account} + + {value == null && ( + 待映射 + )} + {isSkipped && ( + 暂不导入 + )} + + + + ) + })} + + +
+

+ {pendingCount > 0 + ? `还有 ${pendingCount} 个账户待映射,可手动选择或选择暂不导入。` + : "全部账户已处理,可前往数据预览并导入。"} +

+ +
+
+
+ )} +
+ + + {parsedRows.length === 0 ? ( + + + + + + 暂无可预览的数据 + + 上传并解析月结单后,可在此逐笔核对交易明细。 + + + + + + + ) : ( + <> +
+ + + + 交易日期 + 描述 + + 原始金额/币种 + + 金额/币种 + + + + {parsedRows.map((row, index) => { + const amtText = row.amt.trim() + const negative = + amtText.startsWith("-") || /^\(.*\)$/.test(amtText) + + return ( + + + {row.transDate || "—"} + + + + {row.description || "—"} + + + + {row.fxAmt + ? `${row.fxAmt}${row.fxCcy ? ` ${row.fxCcy}` : ""}` + : "—"} + + + {row.amt + ? `${row.amt}${row.ccy ? ` ${row.ccy}` : ""}` + : "—"} + + + ) + })} + +
+
+ +
+
+

+ {hasUnmappedAccount + ? `还有 ${pendingCount} 个账户待映射,请先在「账户映射」处理或选择暂不导入。` + : `共 ${parsedRows.length} 行,将导入 ${importableCount} 行,暂不导入 ${skipRowCount} 行。`} +

+ +
+ + {result && ( +
+ {result.success && ( + + + 导入完成 + +

+ 成功导入 {result.imported ?? 0} 行,跳过重复{" "} + {result.skipped ?? 0} 行。 +

+
+ +
+
+
+ )} + + {!result.success && result.error && ( + + + 导入失败 + {result.error} + + )} + + {result.unmatchedAccounts && + result.unmatchedAccounts.length > 0 && ( + + + 部分账户未能匹配渠道 + +

+ 以下账户未匹配到已创建的支付渠道,请先到支付渠道页创建对应渠道,或返回「账户映射」手动处理后重新导入: +

+
+ {result.unmatchedAccounts.map((account) => ( + + {account} + + ))} +
+
+
+ )} +
+ )} +
+ + )} +
+
+
+ ) +} diff --git a/app/import/page.tsx b/app/import/page.tsx new file mode 100644 index 0000000..94d540d --- /dev/null +++ b/app/import/page.tsx @@ -0,0 +1,41 @@ +import { redirect } from "next/navigation" +import { auth } from "@/lib/auth" +import { getChannelsAction } from "@/lib/actions/channel" +import { AppSidebar } from "@/components/app-sidebar" +import { AppHeader } from "@/components/app-header" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" +import { ImportView } from "./import-view" + +export default async function ImportPage() { + const session = await auth() + + if (!session?.user) { + redirect("/login") + } + + const user = session.user + const userName = user.name || "Fluxent 用户" + const userEmail = user.email || "" + const userAvatar = user.image || null + + const channelsRes = await getChannelsAction() + const channels = + channelsRes.success && channelsRes.data ? channelsRes.data : [] + + return ( + + + + + + + + + ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 05de500..10dca39 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import * as React from "react" import Link from "next/link" import { CreditCardIcon, + FileUpIcon, LayoutDashboardIcon, PenToolIcon, ReceiptTextIcon, @@ -35,6 +36,11 @@ const navMainItems: NavMainItem[] = [ url: "/bookkeeping", icon: PenToolIcon, }, + { + title: "导入账单", + url: "/import", + icon: FileUpIcon, + }, { title: "交易记录", url: "/transactions", diff --git a/components/ui/empty.tsx b/components/ui/empty.tsx new file mode 100644 index 0000000..e11ce34 --- /dev/null +++ b/components/ui/empty.tsx @@ -0,0 +1,103 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx new file mode 100644 index 0000000..76ff6f9 --- /dev/null +++ b/components/ui/spinner.tsx @@ -0,0 +1,16 @@ +import { cn } from "cn" +import { Loader2Icon } from "lucide-react" + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ) +} + +export { Spinner } diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..3303320 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,115 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..74e4766 --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,81 @@ +"use client" + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/lib/actions/import.ts b/lib/actions/import.ts new file mode 100644 index 0000000..a632341 --- /dev/null +++ b/lib/actions/import.ts @@ -0,0 +1,342 @@ +"use server" + +import { and, eq, isNull, like } from "drizzle-orm" +import { revalidatePath } from "next/cache" +import { auth } from "@/lib/auth" +import { db } from "@/lib/db" +import { + channels, + transactions, + transactionsDirty, + type NewTransactionDirty, +} from "@/lib/db/schema" + +export interface CcbaParsedRow { + transDate: string + postDate: string + description: string + refNo: string + amt: string + ccy: string + account: string + fxAmt: string | null + fxCcy: string | null + fxRate: string | null +} + +export interface CcbaImportResult { + success: boolean + imported?: number + skipped?: number + unmatchedAccounts?: string[] + error?: string +} + +/** 存入 rawData 的原始行结构(含去重签名) */ +interface CcbaRawData { + transDate: string + postDate: string + description: string + refNo: string + amt: string + ccy: string + account: string + fxAmt: string | null + fxCcy: string | null + fxRate: string | null + dedupHash: string +} + +interface ChannelMatchContext { + userChannelIds: Set + fullDigitsMap: Map + suffixMap: Map +} + +async function requireUser() { + const session = await auth() + if (!session?.user?.id) { + throw new Error("请先登录") + } + return session.user.id +} + +/** 去掉金额字符串的前导正负号,保留原始精度(不经过浮点往返) */ +function stripSign(value: string): string { + return value.trim().replace(/^[-+]/, "") +} + +/** 解析 CCBA "YYYY-MM-DD" 日期,非法时返回 null */ +function parseImportDate(value: string): Date | null { + const date = new Date(`${value}T00:00:00`) + return Number.isNaN(date.getTime()) ? null : date +} + +/** 从账户描述中提取不少于 12 位的连续数字卡号 */ +function extractCardDigits( + account: string +): { fullDigits: string; last4: string } | null { + const match = /\d{12,}/.exec(account) + if (!match) return null + return { fullDigits: match[0], last4: match[0].slice(-4) } +} + +/** 去掉描述末尾的大写两位国家码,返回商户名与地理位置 */ +function splitMerchantAndGeo(description: string): { + merchantName: string + geo: string | null +} { + const trimmed = description.trim() + const match = /\s+([A-Z]{2})$/.exec(trimmed) + if (!match) { + return { merchantName: trimmed.slice(0, 255), geo: null } + } + return { + merchantName: trimmed.slice(0, match.index).trim().slice(0, 255), + geo: match[1], + } +} + +/** 构建渠道匹配索引:完整卡号 -> id、卡号后缀 -> id */ +function buildChannelMatchContext( + userChannels: { + id: string + cardNumberFull: string | null + cardNumberSuffix: string | null + }[] +): ChannelMatchContext { + const userChannelIds = new Set() + const fullDigitsMap = new Map() + const suffixMap = new Map() + + for (const channel of userChannels) { + userChannelIds.add(channel.id) + + const fullDigits = channel.cardNumberFull?.replace(/\D/g, "") ?? "" + if (fullDigits && !fullDigitsMap.has(fullDigits)) { + fullDigitsMap.set(fullDigits, channel.id) + } + + const suffix = channel.cardNumberSuffix?.replace(/\D/g, "") ?? "" + if (suffix.length >= 4 && !suffixMap.has(suffix)) { + suffixMap.set(suffix, channel.id) + } + } + + return { userChannelIds, fullDigitsMap, suffixMap } +} + +/** + * 解析账户字符串对应的渠道 id: + * 手动映射优先(且必须属于当前用户),否则按完整卡号、再按后四位自动匹配。 + */ +function resolveChannelId( + account: string, + accountChannelMap: Record | undefined, + ctx: ChannelMatchContext +): string | null { + const manualId = accountChannelMap?.[account] + if (manualId && ctx.userChannelIds.has(manualId)) { + return manualId + } + + const digits = extractCardDigits(account) + if (!digits) return null + + const byFull = ctx.fullDigitsMap.get(digits.fullDigits) + if (byFull) return byFull + + return ctx.suffixMap.get(digits.last4) ?? null +} + +/** 导入去重签名:优先银行流水号,缺失时退化为行内容哈希 */ +function buildSignature(row: CcbaParsedRow): string { + const refNo = row.refNo.trim() + return refNo !== "" + ? `ccba:ref:${refNo}` + : `ccba:hash:${row.transDate}|${row.account}|${row.amt}|${row.description}` +} + +export async function importCcbaRowsAction( + rows: CcbaParsedRow[], + accountChannelMap?: Record +): Promise { + try { + const userId = await requireUser() + + if (rows.length === 0) { + return { success: false, error: "没有可导入的账单记录" } + } + + const userChannels = await db + .select({ + id: channels.id, + cardNumberFull: channels.cardNumberFull, + cardNumberSuffix: channels.cardNumberSuffix, + }) + .from(channels) + .where(and(eq(channels.userId, userId), isNull(channels.deletedAt))) + + const ctx = buildChannelMatchContext(userChannels) + + // 已存在的导入签名(待清洗表 + 正式账本) + const [dirtyExts, txnExts] = await Promise.all([ + db + .select({ ext: transactionsDirty.ext }) + .from(transactionsDirty) + .where( + and( + eq(transactionsDirty.userId, userId), + isNull(transactionsDirty.deletedAt), + like(transactionsDirty.ext, "ccba:%") + ) + ), + db + .select({ ext: transactions.ext }) + .from(transactions) + .where( + and( + eq(transactions.userId, userId), + isNull(transactions.deletedAt), + like(transactions.ext, "ccba:%") + ) + ), + ]) + + const existingSignatures = new Set() + for (const existing of dirtyExts) { + if (existing.ext) existingSignatures.add(existing.ext) + } + for (const existing of txnExts) { + if (existing.ext) existingSignatures.add(existing.ext) + } + + const insertedValues: NewTransactionDirty[] = [] + const batchSignatures = new Set() + const unmatchedAccounts = new Set() + let skipped = 0 + + for (const row of rows) { + const txnDate = parseImportDate(row.transDate) + const postingDate = parseImportDate(row.postDate) + if (!txnDate || !postingDate) { + skipped++ + continue + } + + const amtNum = parseFloat(row.amt) + if (Number.isNaN(amtNum)) { + skipped++ + continue + } + + const channelId = resolveChannelId(row.account, accountChannelMap, ctx) + if (!channelId) { + unmatchedAccounts.add(row.account) + continue + } + + const signature = buildSignature(row) + if (existingSignatures.has(signature) || batchSignatures.has(signature)) { + skipped++ + continue + } + batchSignatures.add(signature) + + const ccy = row.ccy.toUpperCase() + const isForeign = Boolean( + row.fxAmt && row.fxCcy && row.fxCcy.toUpperCase() !== ccy + ) + const { merchantName, geo } = splitMerchantAndGeo(row.description) + + const dcFlag = amtNum < 0 ? "CREDIT" : "DEBIT" + let txnScene = "PAYMENT" + if (row.description.includes("INHOUSE FUND TRANSFER")) { + txnScene = "TRANSFER" + } else if (amtNum < 0 && /rebate/i.test(row.description)) { + txnScene = "MISC_IN" + } + + let txnAmt: string + let txnCcy: string + let postingAmt: string | null + let postingCcy: string | null + let fxRates: { fromCcy: string; toCcy: string; rate: string }[] | null + + if (isForeign && row.fxAmt && row.fxCcy) { + const fxCcy = row.fxCcy.toUpperCase() + txnAmt = stripSign(row.fxAmt) + txnCcy = fxCcy + postingAmt = stripSign(row.amt) + postingCcy = ccy + fxRates = row.fxRate + ? [{ fromCcy: fxCcy, toCcy: ccy, rate: row.fxRate }] + : null + } else { + txnAmt = stripSign(row.amt) + txnCcy = ccy + postingAmt = null + postingCcy = null + fxRates = null + } + + const rawData: CcbaRawData = { + transDate: row.transDate, + postDate: row.postDate, + description: row.description, + refNo: row.refNo, + amt: row.amt, + ccy: row.ccy, + account: row.account, + fxAmt: row.fxAmt, + fxCcy: row.fxCcy, + fxRate: row.fxRate, + dedupHash: signature, + } + + insertedValues.push({ + userId, + txnDate, + postingDate, + txnAmt, + txnCcy, + postingAmt, + postingCcy, + fxRates, + dcFlag, + refChannels: [channelId], + txnScene, + merchantName, + description: row.description, + rawDescription: row.description, + rawData, + // ext 存导入去重签名;银行 ref_no 仅保留在上方 rawData 内 + ext: signature, + geo, + }) + } + + if (insertedValues.length > 0) { + await db.insert(transactionsDirty).values(insertedValues) + } + + revalidatePath("/bookkeeping") + revalidatePath("/import") + + const result: CcbaImportResult = { + success: true, + imported: insertedValues.length, + skipped, + } + if (unmatchedAccounts.size > 0) { + result.unmatchedAccounts = Array.from(unmatchedAccounts) + } + return result + } catch (err) { + console.error("importCcbaRowsAction error:", err) + return { + success: false, + error: err instanceof Error ? err.message : "导入账单失败", + } + } +}