FL6 完善财务台账与导出审计
这个提交包含在:
@@ -2,11 +2,30 @@ const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { z } = require("zod");
|
||||
const {
|
||||
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
|
||||
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
|
||||
REIMBURSEMENT_LEDGER_STATUS_VALUES,
|
||||
listFinanceLedger,
|
||||
} = require("../services/financeLedger");
|
||||
|
||||
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
||||
|
||||
const FINANCE_LEDGER_QUERY_SCHEMA = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
status: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
|
||||
paymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
|
||||
invoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
|
||||
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
|
||||
function ensureReadPermission(fastify, request) {
|
||||
if (
|
||||
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
|
||||
!fastify.hasPermission(request.employee, "risk.review.manage")
|
||||
!FINANCE_LEDGER_PERMISSION_CODES.some((permission) => fastify.hasPermission(request.employee, permission))
|
||||
) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
@@ -182,6 +201,24 @@ async function apOperationsRoutes(fastify) {
|
||||
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/ap-operations/reimbursement-ledger", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
ensureReadPermission(fastify, request);
|
||||
|
||||
const parsed = FINANCE_LEDGER_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const ledger = await listFinanceLedger(fastify.prisma, parsed.data);
|
||||
return sendData(reply, request, ledger);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = apOperationsRoutes;
|
||||
|
||||
+61
-1
@@ -19,6 +19,14 @@ const {
|
||||
} = require("../services/employeeDirectoryFilters");
|
||||
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
FINANCE_LEDGER_EXPORT_HEADERS,
|
||||
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
|
||||
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
|
||||
REIMBURSEMENT_LEDGER_STATUS_VALUES,
|
||||
listFinanceLedgerForExport,
|
||||
mapFinanceLedgerExportRow,
|
||||
} = require("../services/financeLedger");
|
||||
const {
|
||||
HISTORY_STATUS_VALUES,
|
||||
buildPresenceValidationError,
|
||||
@@ -109,6 +117,12 @@ const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({
|
||||
pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(),
|
||||
pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(),
|
||||
riskBucket: z.enum(["all", "exception", "budget_overspend"]).optional(),
|
||||
ledgerStatus: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
|
||||
ledgerPaymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
|
||||
ledgerInvoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
|
||||
ledgerDateFrom: DATE_ONLY_SCHEMA.optional(),
|
||||
ledgerDateTo: DATE_ONLY_SCHEMA.optional(),
|
||||
ledgerSearch: z.string().trim().max(100).optional(),
|
||||
}).strict();
|
||||
|
||||
const PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
|
||||
@@ -573,8 +587,23 @@ async function loadApOperationsExportDataset(prisma, filters) {
|
||||
resolution_status: "pending",
|
||||
match_status: { not: "matched" },
|
||||
};
|
||||
const ledgerFilters = {
|
||||
status: filters.ledgerStatus,
|
||||
paymentStatus: filters.ledgerPaymentStatus,
|
||||
invoiceStatus: filters.ledgerInvoiceStatus,
|
||||
dateFrom: filters.ledgerDateFrom,
|
||||
dateTo: filters.ledgerDateTo,
|
||||
q: filters.ledgerSearch,
|
||||
};
|
||||
|
||||
const [pendingPaymentBatches, failedPaymentBatchItems, riskFlags, reconciliationAnomalies] = await Promise.all([
|
||||
const [
|
||||
reimbursementLedger,
|
||||
pendingPaymentBatches,
|
||||
failedPaymentBatchItems,
|
||||
riskFlags,
|
||||
reconciliationAnomalies,
|
||||
] = await Promise.all([
|
||||
listFinanceLedgerForExport(prisma, ledgerFilters, 5000),
|
||||
prisma.paymentBatch.findMany({
|
||||
where: pendingWhere,
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
@@ -617,6 +646,7 @@ async function loadApOperationsExportDataset(prisma, filters) {
|
||||
]);
|
||||
|
||||
return {
|
||||
reimbursementLedger,
|
||||
pendingWhere,
|
||||
riskWhere,
|
||||
reconciliationWhere,
|
||||
@@ -630,6 +660,13 @@ async function loadApOperationsExportDataset(prisma, filters) {
|
||||
async function* streamApOperationsCsv(dataset) {
|
||||
yield "\ufeff";
|
||||
|
||||
yield csvRow(["财务台账"]);
|
||||
yield csvRow(FINANCE_LEDGER_EXPORT_HEADERS);
|
||||
for (const item of dataset.reimbursementLedger) {
|
||||
yield csvRow(mapFinanceLedgerExportRow(item));
|
||||
}
|
||||
yield csvRow([]);
|
||||
|
||||
yield csvRow(["待付款批次"]);
|
||||
yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]);
|
||||
for (const item of dataset.pendingPaymentBatches) {
|
||||
@@ -661,6 +698,17 @@ async function* streamApOperationsCsv(dataset) {
|
||||
async function buildApOperationsXlsx(dataset) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
||||
const ledgerSheet = workbook.addWorksheet("财务台账");
|
||||
ledgerSheet.columns = FINANCE_LEDGER_EXPORT_HEADERS.map((header, index) => ({
|
||||
header,
|
||||
key: `col${index}`,
|
||||
width: index === 0 ? 24 : index === 14 ? 24 : 16,
|
||||
}));
|
||||
for (const item of dataset.reimbursementLedger) {
|
||||
const row = mapFinanceLedgerExportRow(item);
|
||||
ledgerSheet.addRow(Object.fromEntries(row.map((value, index) => [`col${index}`, value])));
|
||||
}
|
||||
|
||||
const pendingSheet = workbook.addWorksheet("待付款批次");
|
||||
pendingSheet.columns = [
|
||||
{ header: "批次编号", key: "batchNo", width: 24 },
|
||||
@@ -1617,6 +1665,12 @@ async function exportsRoutes(fastify) {
|
||||
pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom,
|
||||
pendingCreatedAtTo: parsedBody.pendingCreatedAtTo,
|
||||
riskBucket: parsedBody.riskBucket ?? "all",
|
||||
ledgerStatus: parsedBody.ledgerStatus,
|
||||
ledgerPaymentStatus: parsedBody.ledgerPaymentStatus,
|
||||
ledgerInvoiceStatus: parsedBody.ledgerInvoiceStatus,
|
||||
ledgerDateFrom: parsedBody.ledgerDateFrom,
|
||||
ledgerDateTo: parsedBody.ledgerDateTo,
|
||||
ledgerSearch: parsedBody.ledgerSearch,
|
||||
};
|
||||
const dataset = await loadApOperationsExportDataset(fastify.prisma, filters);
|
||||
|
||||
@@ -1635,6 +1689,12 @@ async function exportsRoutes(fastify) {
|
||||
failedPaymentItemTotal: dataset.failedPaymentBatchItems.length,
|
||||
openRiskFlagTotal: dataset.riskFlags.length,
|
||||
reconciliationAnomalyTotal: dataset.reconciliationAnomalies.length,
|
||||
financeLedgerTotal: dataset.reimbursementLedger.length,
|
||||
ledgerStatus: filters.ledgerStatus ?? null,
|
||||
ledgerPaymentStatus: filters.ledgerPaymentStatus ?? null,
|
||||
ledgerInvoiceStatus: filters.ledgerInvoiceStatus ?? null,
|
||||
ledgerDateFrom: filters.ledgerDateFrom ?? null,
|
||||
ledgerDateTo: filters.ledgerDateTo ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -308,7 +308,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
|
||||
return items.map(buildPersistedRiskFlag);
|
||||
}
|
||||
|
||||
async function executeReimbursementPayment(tx, { batch, item, actorId, index }) {
|
||||
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) {
|
||||
const current = await tx.reimbursementForm.findUnique({
|
||||
where: { id: item.entity_id },
|
||||
select: {
|
||||
@@ -348,11 +348,31 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
|
||||
if (conflict) {
|
||||
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
|
||||
}
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId,
|
||||
action: "REIMBURSEMENT_PAYMENT_MARKED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: current.id,
|
||||
extra: {
|
||||
formNumber: current.form_number ?? null,
|
||||
paymentVoucherNo: current.payment_voucher_no ?? null,
|
||||
paymentDate: existingPaymentDate,
|
||||
paymentChannel: current.payment_channel ?? null,
|
||||
paymentNote: null,
|
||||
idempotent: true,
|
||||
archivedInvoiceCount: 0,
|
||||
source: "payment_batch",
|
||||
paymentBatchId: batch.id,
|
||||
paymentBatchItemId: item.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { idempotent: true };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await tx.invoicePool.updateMany({
|
||||
const archivedResult = await tx.invoicePool.updateMany({
|
||||
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
|
||||
data: {
|
||||
status: "archived",
|
||||
@@ -373,6 +393,26 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
|
||||
updated_by: actorId,
|
||||
},
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId,
|
||||
action: "REIMBURSEMENT_PAYMENT_MARKED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: current.id,
|
||||
extra: {
|
||||
formNumber: current.form_number ?? null,
|
||||
paymentVoucherNo: voucher,
|
||||
paymentDate,
|
||||
paymentChannel,
|
||||
paymentNote: null,
|
||||
idempotent: false,
|
||||
archivedInvoiceCount: archivedResult.count,
|
||||
source: "payment_batch",
|
||||
paymentBatchId: batch.id,
|
||||
paymentBatchItemId: item.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { idempotent: false };
|
||||
}
|
||||
|
||||
@@ -842,7 +882,13 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
try {
|
||||
if (batch.entity_type === "reimbursement") {
|
||||
const paymentResult = await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index });
|
||||
const paymentResult = await executeReimbursementPayment(tx, {
|
||||
batch,
|
||||
item,
|
||||
actorId: request.employee.id,
|
||||
index,
|
||||
request,
|
||||
});
|
||||
if (!paymentResult.idempotent) {
|
||||
successfulReimbursementIds.push(item.entity_id);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const PAY_BODY_SCHEMA = z.object({
|
||||
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
|
||||
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
|
||||
paymentNote: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||
@@ -1556,6 +1557,19 @@ async function reimbursementRoutes(fastify) {
|
||||
reimbursementDate,
|
||||
status: reimbursement.status,
|
||||
description: reimbursement.description,
|
||||
payment: {
|
||||
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
|
||||
paymentDate: toDateOnlyString(reimbursement.payment_date),
|
||||
paymentChannel: reimbursement.payment_channel ?? null,
|
||||
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
|
||||
paidBy: reimbursement.paid_by_employee
|
||||
? {
|
||||
id: reimbursement.paid_by_employee.id,
|
||||
employeeNo: reimbursement.paid_by_employee.employee_no,
|
||||
name: reimbursement.paid_by_employee.name,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
projectId: reimbursement.project_id ?? null,
|
||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||
@@ -1958,8 +1972,19 @@ async function reimbursementRoutes(fastify) {
|
||||
total_amount: true,
|
||||
status: true,
|
||||
description: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
payment_channel: true,
|
||||
paid_at: true,
|
||||
project_id: true,
|
||||
linked_travel_request_id: true,
|
||||
paid_by_employee: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
department: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2158,6 +2183,19 @@ async function reimbursementRoutes(fastify) {
|
||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||
status: reimbursement.status,
|
||||
description: reimbursement.description,
|
||||
payment: {
|
||||
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
|
||||
paymentDate: toDateOnlyString(reimbursement.payment_date),
|
||||
paymentChannel: reimbursement.payment_channel ?? null,
|
||||
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
|
||||
paidBy: reimbursement.paid_by_employee
|
||||
? {
|
||||
id: reimbursement.paid_by_employee.id,
|
||||
employeeNo: reimbursement.paid_by_employee.employee_no,
|
||||
name: reimbursement.paid_by_employee.name,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
projectId: reimbursement.project_id ?? null,
|
||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||
@@ -3737,6 +3775,33 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_PAYMENT_MARKED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
formNumber: reimbursement.form_number ?? null,
|
||||
paymentVoucherNo:
|
||||
reimbursement.status === "paid"
|
||||
? reimbursement.payment_voucher_no ?? null
|
||||
: payInput.paymentVoucherNo ?? null,
|
||||
paymentDate:
|
||||
reimbursement.status === "paid"
|
||||
? existingPaymentDate
|
||||
: payInput.paymentDate ?? null,
|
||||
paymentChannel:
|
||||
reimbursement.status === "paid"
|
||||
? reimbursement.payment_channel ?? null
|
||||
: payInput.paymentChannel ?? null,
|
||||
paymentNote: payInput.paymentNote ?? null,
|
||||
idempotent: reimbursement.status === "paid",
|
||||
archivedInvoiceCount: archivedResult.count,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
response = {
|
||||
reimbursementId,
|
||||
outcome: "paid",
|
||||
@@ -3764,6 +3829,7 @@ async function reimbursementRoutes(fastify) {
|
||||
reimbursement.status === "paid"
|
||||
? reimbursement.paid_by ?? null
|
||||
: request.employee.id,
|
||||
paymentNote: payInput.paymentNote ?? null,
|
||||
idempotent: reimbursement.status === "paid",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
const { toMoneyString } = require("./feeControl");
|
||||
|
||||
const REIMBURSEMENT_LEDGER_STATUS_VALUES = Object.freeze([
|
||||
"draft",
|
||||
"reviewing",
|
||||
"approved",
|
||||
"paid",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]);
|
||||
const REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES = Object.freeze(["unpaid", "paid"]);
|
||||
const REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES = Object.freeze([
|
||||
"none",
|
||||
"pending_ocr",
|
||||
"ocr_failed",
|
||||
"available",
|
||||
"bound",
|
||||
"archived",
|
||||
]);
|
||||
|
||||
function toDateOnlyString(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value.slice(0, 10);
|
||||
return value.toISOString?.().slice(0, 10) ?? null;
|
||||
}
|
||||
|
||||
function toDateTimeString(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value;
|
||||
return value.toISOString?.() ?? null;
|
||||
}
|
||||
|
||||
function dateRange(dateFrom, dateTo) {
|
||||
return {
|
||||
...(dateFrom ? { gte: new Date(`${dateFrom}T00:00:00.000Z`) } : {}),
|
||||
...(dateTo ? { lte: new Date(`${dateTo}T23:59:59.999Z`) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFinanceLedgerWhere(filters = {}) {
|
||||
const clauses = [];
|
||||
const query = filters.q?.trim();
|
||||
|
||||
if (filters.status) {
|
||||
clauses.push({ status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.paymentStatus === "paid") {
|
||||
clauses.push({ status: "paid" });
|
||||
} else if (filters.paymentStatus === "unpaid") {
|
||||
clauses.push({ status: { not: "paid" } });
|
||||
}
|
||||
|
||||
if (filters.invoiceStatus === "none") {
|
||||
clauses.push({ invoice_pool_items: { none: {} } });
|
||||
} else if (filters.invoiceStatus) {
|
||||
clauses.push({ invoice_pool_items: { some: { status: filters.invoiceStatus } } });
|
||||
}
|
||||
|
||||
const reimbursementDateRange = dateRange(filters.dateFrom, filters.dateTo);
|
||||
if (Object.keys(reimbursementDateRange).length > 0) {
|
||||
clauses.push({ reimbursement_date: reimbursementDateRange });
|
||||
}
|
||||
|
||||
if (query) {
|
||||
clauses.push({
|
||||
OR: [
|
||||
{ form_number: { contains: query, mode: "insensitive" } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
{ owner: { name: { contains: query, mode: "insensitive" } } },
|
||||
{ owner: { employee_no: { contains: query, mode: "insensitive" } } },
|
||||
{ submitter: { name: { contains: query, mode: "insensitive" } } },
|
||||
{ submitter: { employee_no: { contains: query, mode: "insensitive" } } },
|
||||
{ department: { name: { contains: query, mode: "insensitive" } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return clauses.length ? { AND: clauses } : {};
|
||||
}
|
||||
|
||||
const FINANCE_LEDGER_SELECT = {
|
||||
id: true,
|
||||
form_number: true,
|
||||
reimbursement_date: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
description: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
payment_channel: true,
|
||||
paid_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
submitter: { select: { id: true, employee_no: true, name: true } },
|
||||
department: { select: { id: true, name: true } },
|
||||
paid_by_employee: { select: { id: true, employee_no: true, name: true } },
|
||||
invoice_pool_items: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
archived_reason: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
attachments: true,
|
||||
invoice_pool_items: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function summarizeInvoiceStatus(invoices) {
|
||||
const counts = {
|
||||
total: invoices.length,
|
||||
pendingOcr: 0,
|
||||
ocrFailed: 0,
|
||||
available: 0,
|
||||
bound: 0,
|
||||
archived: 0,
|
||||
usedComplete: 0,
|
||||
};
|
||||
|
||||
for (const invoice of invoices) {
|
||||
if (invoice.status === "pending_ocr") counts.pendingOcr += 1;
|
||||
if (invoice.status === "ocr_failed") counts.ocrFailed += 1;
|
||||
if (invoice.status === "available") counts.available += 1;
|
||||
if (invoice.status === "bound") counts.bound += 1;
|
||||
if (invoice.status === "archived") counts.archived += 1;
|
||||
if (invoice.archived_reason === "used_complete") counts.usedComplete += 1;
|
||||
}
|
||||
|
||||
if (counts.total === 0) return { status: "none", counts };
|
||||
if (counts.ocrFailed > 0) return { status: "ocr_failed", counts };
|
||||
if (counts.pendingOcr > 0) return { status: "pending_ocr", counts };
|
||||
if (counts.total === counts.archived) return { status: "archived", counts };
|
||||
if (counts.bound > 0) return { status: "bound", counts };
|
||||
if (counts.total === counts.available) return { status: "available", counts };
|
||||
return { status: "mixed", counts };
|
||||
}
|
||||
|
||||
function mapEmployee(employee) {
|
||||
return employee
|
||||
? {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function mapFinanceLedgerItem(item) {
|
||||
const invoiceSummary = summarizeInvoiceStatus(item.invoice_pool_items ?? []);
|
||||
return {
|
||||
id: item.id,
|
||||
formNumber: item.form_number,
|
||||
reimbursementDate: toDateOnlyString(item.reimbursement_date),
|
||||
status: item.status,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
description: item.description ?? null,
|
||||
owner: mapEmployee(item.owner),
|
||||
submitter: mapEmployee(item.submitter),
|
||||
department: item.department ? { id: item.department.id, name: item.department.name } : null,
|
||||
payment: {
|
||||
status: item.status === "paid" ? "paid" : "unpaid",
|
||||
paymentVoucherNo: item.payment_voucher_no ?? null,
|
||||
paymentDate: toDateOnlyString(item.payment_date),
|
||||
paymentChannel: item.payment_channel ?? null,
|
||||
paidAt: toDateTimeString(item.paid_at),
|
||||
paidBy: mapEmployee(item.paid_by_employee),
|
||||
},
|
||||
invoice: invoiceSummary,
|
||||
attachmentCount: item._count?.attachments ?? 0,
|
||||
createdAt: toDateTimeString(item.created_at),
|
||||
updatedAt: toDateTimeString(item.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
async function listFinanceLedger(prisma, filters = {}) {
|
||||
const page = Math.max(1, Number(filters.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize ?? 20)));
|
||||
const where = buildFinanceLedgerWhere(filters);
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.reimbursementForm.count({ where }),
|
||||
prisma.reimbursementForm.findMany({
|
||||
where,
|
||||
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: FINANCE_LEDGER_SELECT,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
items: rows.map(mapFinanceLedgerItem),
|
||||
};
|
||||
}
|
||||
|
||||
async function listFinanceLedgerForExport(prisma, filters = {}, limit = 5000) {
|
||||
const rows = await prisma.reimbursementForm.findMany({
|
||||
where: buildFinanceLedgerWhere(filters),
|
||||
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
|
||||
take: limit,
|
||||
select: FINANCE_LEDGER_SELECT,
|
||||
});
|
||||
return rows.map(mapFinanceLedgerItem);
|
||||
}
|
||||
|
||||
function paymentStatusLabel(status) {
|
||||
return status === "paid" ? "已付款" : "未付款";
|
||||
}
|
||||
|
||||
function reimbursementStatusLabel(status) {
|
||||
const labels = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
paid: "已支付",
|
||||
rejected: "已驳回",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
return labels[status] ?? status ?? "";
|
||||
}
|
||||
|
||||
function invoiceStatusLabel(status) {
|
||||
const labels = {
|
||||
none: "无发票",
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可添加",
|
||||
bound: "已绑定",
|
||||
archived: "已归档",
|
||||
mixed: "多状态",
|
||||
};
|
||||
return labels[status] ?? status ?? "";
|
||||
}
|
||||
|
||||
const FINANCE_LEDGER_EXPORT_HEADERS = Object.freeze([
|
||||
"报销单号",
|
||||
"申请人",
|
||||
"部门",
|
||||
"金额",
|
||||
"审批状态",
|
||||
"付款状态",
|
||||
"付款日期",
|
||||
"付款凭证号",
|
||||
"付款渠道",
|
||||
"付款操作人",
|
||||
"发票状态",
|
||||
"发票数",
|
||||
"附件数",
|
||||
"报销日期",
|
||||
"更新时间",
|
||||
]);
|
||||
|
||||
function mapFinanceLedgerExportRow(item) {
|
||||
return [
|
||||
item.formNumber ?? item.id,
|
||||
item.owner?.name ?? item.submitter?.name ?? "",
|
||||
item.department?.name ?? "",
|
||||
item.totalAmount,
|
||||
reimbursementStatusLabel(item.status),
|
||||
paymentStatusLabel(item.payment.status),
|
||||
item.payment.paymentDate ?? "",
|
||||
item.payment.paymentVoucherNo ?? "",
|
||||
item.payment.paymentChannel ?? "",
|
||||
item.payment.paidBy?.name ?? "",
|
||||
invoiceStatusLabel(item.invoice.status),
|
||||
item.invoice.counts.total,
|
||||
item.attachmentCount,
|
||||
item.reimbursementDate ?? "",
|
||||
item.updatedAt ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FINANCE_LEDGER_EXPORT_HEADERS,
|
||||
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
|
||||
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
|
||||
REIMBURSEMENT_LEDGER_STATUS_VALUES,
|
||||
buildFinanceLedgerWhere,
|
||||
invoiceStatusLabel,
|
||||
listFinanceLedger,
|
||||
listFinanceLedgerForExport,
|
||||
mapFinanceLedgerExportRow,
|
||||
paymentStatusLabel,
|
||||
reimbursementStatusLabel,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户