文件
hyb-oa/server/src/services/financeLedger.js
T

305 行
8.7 KiB
JavaScript

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" } },
{ project: { is: { project_no: { contains: query, mode: "insensitive" } } } },
{ project: { is: { name: { 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 } },
project: { select: { id: true, project_no: 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,
project: item.project
? {
id: item.project.id,
projectNo: item.project.project_no,
name: item.project.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.project ? [item.project.projectNo, item.project.name].filter(Boolean).join(" ") : "",
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,
};