"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 : "导入账单失败", } } }