"use server" import { z } from "zod" import { and, desc, eq, inArray, isNull } from "drizzle-orm" import { revalidatePath } from "next/cache" import { auth } from "@/lib/auth" import { db } from "@/lib/db" import { channels, transactions, transactionsDirty, type TransactionDirty, } from "@/lib/db/schema" async function requireUser() { const session = await auth() if (!session?.user?.id) { throw new Error("请先登录") } return session.user.id } const dirtyTransactionSchema = z.object({ txnDate: z.string().min(1, "请选择交易日期"), txnAmt: z.string().min(1, "请输入交易金额"), txnCcy: z.string().min(1, "请输入交易币种").max(10), postingAmt: z.string().optional().nullable(), postingCcy: z.string().max(10).optional().nullable(), commAmt: z.string().optional().nullable(), commCcy: z.string().max(10).optional().nullable(), surchargeAmt: z.string().optional().nullable(), surchargeCcy: z.string().max(10).optional().nullable(), discAmt: z.string().optional().nullable(), discCcy: z.string().max(10).optional().nullable(), dcFlag: z.enum(["DEBIT", "CREDIT"] as const).default("DEBIT"), refChannels: z.array(z.string().uuid()).default([]), txnScene: z.string().min(1).default("PAYMENT"), merchantName: z.string().max(255).optional().nullable(), description: z.string().max(500).optional().nullable(), memo: z.string().max(500).optional().nullable(), }) export type DirtyTransactionInput = z.infer export async function getDirtyTransactionsAction(): Promise<{ success: boolean data?: TransactionDirty[] error?: string }> { try { const userId = await requireUser() const rows = await db .select() .from(transactionsDirty) .where( and( eq(transactionsDirty.userId, userId), isNull(transactionsDirty.deletedAt) ) ) .orderBy( desc(transactionsDirty.txnDate), desc(transactionsDirty.createdAt) ) return { success: true, data: rows } } catch (err) { console.error("getDirtyTransactionsAction error:", err) return { success: false, error: err instanceof Error ? err.message : "获取待清洗交易失败", } } } export async function createDirtyTransactionAction( data: DirtyTransactionInput ): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> { try { const userId = await requireUser() const parsed = dirtyTransactionSchema.safeParse(data) if (!parsed.success) { return { success: false, error: parsed.error.issues[0]?.message || "参数错误", } } const val = parsed.data const [row] = await db .insert(transactionsDirty) .values({ userId, txnDate: new Date(val.txnDate), txnAmt: val.txnAmt.trim(), txnCcy: val.txnCcy.trim().toUpperCase(), postingAmt: val.postingAmt?.trim() || null, postingCcy: val.postingCcy?.trim() ? val.postingCcy.trim().toUpperCase() : null, commAmt: val.commAmt?.trim() || null, commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null, surchargeAmt: val.surchargeAmt?.trim() || null, surchargeCcy: val.surchargeCcy?.trim() ? val.surchargeCcy.trim().toUpperCase() : null, discAmt: val.discAmt?.trim() || null, discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null, dcFlag: val.dcFlag, refChannels: val.refChannels, txnScene: val.txnScene, merchantName: val.merchantName?.trim() || null, description: val.description?.trim() || null, memo: val.memo?.trim() || null, }) .returning() revalidatePath("/bookkeeping") return { success: true, data: row } } catch (err) { console.error("createDirtyTransactionAction error:", err) return { success: false, error: err instanceof Error ? err.message : "暂存交易失败", } } } export async function updateDirtyTransactionAction( id: string, data: DirtyTransactionInput ): Promise<{ success: boolean; data?: TransactionDirty; error?: string }> { try { const userId = await requireUser() const parsed = dirtyTransactionSchema.safeParse(data) if (!parsed.success) { return { success: false, error: parsed.error.issues[0]?.message || "参数错误", } } const val = parsed.data const [row] = await db .update(transactionsDirty) .set({ txnDate: new Date(val.txnDate), txnAmt: val.txnAmt.trim(), txnCcy: val.txnCcy.trim().toUpperCase(), postingAmt: val.postingAmt?.trim() || null, postingCcy: val.postingCcy?.trim() ? val.postingCcy.trim().toUpperCase() : null, commAmt: val.commAmt?.trim() || null, commCcy: val.commCcy?.trim() ? val.commCcy.trim().toUpperCase() : null, surchargeAmt: val.surchargeAmt?.trim() || null, surchargeCcy: val.surchargeCcy?.trim() ? val.surchargeCcy.trim().toUpperCase() : null, discAmt: val.discAmt?.trim() || null, discCcy: val.discCcy?.trim() ? val.discCcy.trim().toUpperCase() : null, dcFlag: val.dcFlag, refChannels: val.refChannels, txnScene: val.txnScene, merchantName: val.merchantName?.trim() || null, description: val.description?.trim() || null, memo: val.memo?.trim() || null, updatedAt: new Date(), }) .where( and( eq(transactionsDirty.id, id), eq(transactionsDirty.userId, userId), isNull(transactionsDirty.deletedAt) ) ) .returning() if (!row) { return { success: false, error: "未找到该待清洗交易" } } revalidatePath("/bookkeeping") return { success: true, data: row } } catch (err) { console.error("updateDirtyTransactionAction error:", err) return { success: false, error: err instanceof Error ? err.message : "更新交易失败", } } } export async function deleteDirtyTransactionAction( id: string ): Promise<{ success: boolean; error?: string }> { try { const userId = await requireUser() const [deleted] = await db .delete(transactionsDirty) .where( and(eq(transactionsDirty.id, id), eq(transactionsDirty.userId, userId)) ) .returning() if (!deleted) { return { success: false, error: "交易不存在或已被删除" } } revalidatePath("/bookkeeping") return { success: true } } catch (err) { console.error("deleteDirtyTransactionAction error:", err) return { success: false, error: err instanceof Error ? err.message : "删除失败", } } } /** * 校验单笔待清洗交易的合规性与会计平衡 */ function validateDirtyTransaction( item: TransactionDirty, userChannelIds: Set ): string | null { // 1. 金额必须为有效正数 const amt = parseFloat(item.txnAmt) if (isNaN(amt) || amt <= 0) { return `交易金额「${item.txnAmt}」无效,必须为大于0的数值` } // 2. 币种不能为空 if (!item.txnCcy || item.txnCcy.trim() === "") { return "交易币种不能为空" } // 3. 必须绑定属于当前用户的支付渠道 if (!Array.isArray(item.refChannels) || item.refChannels.length === 0) { return "未指定支付渠道,无法过账" } for (const chId of item.refChannels) { if (!userChannelIds.has(chId)) { return "绑定的支付渠道无效或已被删除" } } // 4. 入账金额与币种必须同时存在或同时为空 const hasPostingAmt = item.postingAmt !== null && item.postingAmt.trim() !== "" const hasPostingCcy = item.postingCcy !== null && item.postingCcy.trim() !== "" if (hasPostingAmt !== hasPostingCcy) { return "入账金额与入账币种必须同时提供" } // 5. 若为同币种入账,执行会计恒等式平衡检验 if (hasPostingAmt && item.postingCcy === item.txnCcy) { const pAmt = parseFloat(item.postingAmt!) const comm = item.commAmt && item.commCcy === item.txnCcy ? parseFloat(item.commAmt) : 0 const surcharge = item.surchargeAmt && item.surchargeCcy === item.txnCcy ? parseFloat(item.surchargeAmt) : 0 const disc = item.discAmt && item.discCcy === item.txnCcy ? parseFloat(item.discAmt) : 0 let expected = amt + surcharge - disc if (item.dcFlag === "DEBIT") { expected += comm } else { expected -= comm } // 允许 0.01 的浮点微差 if (Math.abs(pAmt - expected) > 0.015) { return `会计恒等式不平衡:实际入账 ${pAmt} 与计算期望值 ${expected.toFixed(2)} 不符` } } return null } /** * 全量原子阻断清洗动作 (All-or-Nothing) * 只要有哪怕一笔交易存在问题,就全量阻断合并,返回详尽错误清单,绝不污染正式账本。 */ export async function cleanseTransactionsAction( specificIds?: string[] ): Promise<{ success: boolean cleansedCount?: number errors?: { id: string; name: string; error: string }[] error?: string }> { try { const userId = await requireUser() // 1. 获取目标待清洗交易 const queryConditions = [ eq(transactionsDirty.userId, userId), isNull(transactionsDirty.deletedAt), ] if (specificIds && specificIds.length > 0) { queryConditions.push(inArray(transactionsDirty.id, specificIds)) } const dirtyList = await db .select() .from(transactionsDirty) .where(and(...queryConditions)) if (dirtyList.length === 0) { return { success: false, error: "当前暂无待清洗的交易记录" } } // 2. 获取用户的所有有效渠道 ID 用于归属校验 const userChannels = await db .select({ id: channels.id }) .from(channels) .where(and(eq(channels.userId, userId), isNull(channels.deletedAt))) const userChannelIdSet = new Set(userChannels.map((c) => c.id)) // 3. 执行严格的前置全量校验网关 (Pre-flight Validation Gate) const validationErrors: { id: string; name: string; error: string }[] = [] for (const item of dirtyList) { const err = validateDirtyTransaction(item, userChannelIdSet) if (err) { const identifier = item.merchantName || item.description || `交易(${item.txnAmt} ${item.txnCcy})` validationErrors.push({ id: item.id, name: identifier, error: err, }) } } // 4. 零容忍阻断:只要有一项不合格,立即全量中止,不发生任何写入与删除! if (validationErrors.length > 0) { return { success: false, error: `批次中存在 ${validationErrors.length} 笔未平账或要素不全的交易,全量阻断合并!请修正后再试。`, errors: validationErrors, } } // 5. 100% 校验通过,开启原子事务迁移至正式表 const idsToCleanse = dirtyList.map((d) => d.id) await db.transaction(async (tx) => { // 5.1 批量插入正式 transactions 表 await tx.insert(transactions).values( dirtyList.map((item) => { // 若未填入账信息且为单币种,自动补足 const pAmt = item.postingAmt || item.txnAmt const pCcy = item.postingCcy || item.txnCcy return { id: item.id, userId: item.userId, version: item.version, refTransactions: item.refTransactions, txnDate: item.txnDate, clearingDate: item.clearingDate, postingDate: item.postingDate, txnAmt: item.txnAmt, txnCcy: item.txnCcy, postingAmt: pAmt, postingCcy: pCcy, commAmt: item.commAmt, commCcy: item.commCcy, surchargeAmt: item.surchargeAmt, surchargeCcy: item.surchargeCcy, discAmt: item.discAmt, discCcy: item.discCcy, fxRates: item.fxRates, dcFlag: item.dcFlag, refChannels: item.refChannels, cp: item.cp, acqInst: item.acqInst, clearingNetwork: item.clearingNetwork, txnSts: item.txnSts, description: item.description, memo: item.memo, ext: item.ext, rawDescription: item.rawDescription, rawData: item.rawData, txnScene: item.txnScene, merchantName: item.merchantName, orderId: item.orderId, geo: item.geo, } }) ) // 5.2 从 transactions_dirty 表彻底清除已清洗记录 await tx .delete(transactionsDirty) .where( and( eq(transactionsDirty.userId, userId), inArray(transactionsDirty.id, idsToCleanse) ) ) }) // 6. 成功,全量刷新路由缓存 revalidatePath("/bookkeeping") revalidatePath("/transactions") revalidatePath("/accounts") revalidatePath("/") return { success: true, cleansedCount: idsToCleanse.length, } } catch (err) { console.error("cleanseTransactionsAction error:", err) return { success: false, error: err instanceof Error ? err.message : "清洗合并事务失败", } } }