Files
fluxent-web/lib/actions/import.ts
T

343 lines
9.2 KiB
TypeScript

"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<string>
fullDigitsMap: Map<string, string>
suffixMap: Map<string, string>
}
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<string>()
const fullDigitsMap = new Map<string, string>()
const suffixMap = new Map<string, string>()
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<string, string> | 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<string, string>
): Promise<CcbaImportResult> {
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<string>()
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<string>()
const unmatchedAccounts = new Set<string>()
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 : "导入账单失败",
}
}
}