343 lines
9.2 KiB
TypeScript
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 : "导入账单失败",
|
|
}
|
|
}
|
|
}
|