feat(import): add CCBA statement CSV workflow
This commit is contained in:
@@ -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<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 : "导入账单失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user