feat: 补齐报销付款待办交接(09-11-reimbursement-payment-handoffs)
这个提交包含在:
@@ -7,6 +7,7 @@ const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { loadReimbursementPaymentHandoffs } = require("../services/reimbursementPaymentHandoffs");
|
||||
|
||||
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
|
||||
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
|
||||
@@ -455,6 +456,7 @@ async function portalRoutes(fastify) {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
|
||||
const workspace = await getWorkspaceSetting(fastify.prisma);
|
||||
const businessHandoffs = await loadReimbursementPaymentHandoffs(fastify, request.employee);
|
||||
const today = currentDateString(fastify.config.appTimezone);
|
||||
const currentWeekStart = resolveWeekStart(today);
|
||||
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||
@@ -761,6 +763,7 @@ async function portalRoutes(fastify) {
|
||||
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
|
||||
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
|
||||
],
|
||||
businessHandoffs,
|
||||
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
|
||||
announcements: announcementRows.map((item) => ({
|
||||
id: item.id,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const { evaluateFeatureFlag, FEATURE_FLAG_KEYS } = require("./featureFlags");
|
||||
const { buildPaymentProcessingSemantics } = require("./paymentProcessingProgress");
|
||||
|
||||
async function loadReimbursementPaymentHandoffs(fastify, employee) {
|
||||
return fastify.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe("SET TRANSACTION READ ONLY");
|
||||
const items = [];
|
||||
const canManage = fastify.hasPermission(employee, "payment.batch.manage");
|
||||
const canReview = fastify.hasPermission(employee, "payment.batch.review")
|
||||
&& (employee.roleCodes ?? []).some((role) => ["manager", "ceo"].includes(role));
|
||||
if (fastify.hasPermission(employee, "expense.submit") && fastify.hasPermission(employee, "expense.view_own")) {
|
||||
const returned = await tx.reimbursementForm.findMany({
|
||||
where: { deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: employee.id }, { submitter_id: employee.id }] },
|
||||
select: { id: true, form_number: true, workflow_instances: {
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1,
|
||||
select: { workflow_history: { orderBy: [{ created_at: "desc" }, { id: "desc" }], take: 1, select: { action: true } } },
|
||||
} },
|
||||
orderBy: [{ updated_at: "asc" }, { id: "asc" }],
|
||||
});
|
||||
for (const form of returned) {
|
||||
if (!["return", "reject"].includes(form.workflow_instances[0]?.workflow_history[0]?.action)) continue;
|
||||
items.push({ key: `reimbursement-edit:${form.id}`, kind: "reimbursement_edit", reference: form.form_number,
|
||||
title: "报销退回待修改", nextStep: "修改后重新提交,也可删除后重新填报", ownerLabel: "报销人或代填人",
|
||||
href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` });
|
||||
}
|
||||
}
|
||||
if (!canManage && !canReview) return { total: items.length, items };
|
||||
const gate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, strictDepartment: false });
|
||||
if (!gate.enabled) return { total: items.length, items };
|
||||
const departmentGates = new Map();
|
||||
const departmentEnabled = async (departmentId) => {
|
||||
const key = departmentId ?? null;
|
||||
if (!departmentGates.has(key)) departmentGates.set(key, evaluateFeatureFlag({
|
||||
prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.paymentBatch, departmentId: key, strictDepartment: true,
|
||||
}).then((result) => result.enabled));
|
||||
return departmentGates.get(key);
|
||||
};
|
||||
if (canManage) {
|
||||
const approved = await tx.reimbursementForm.findMany({
|
||||
where: { status: "approved", paid_at: null, deleted_at: null },
|
||||
select: { id: true, form_number: true, department_id: true },
|
||||
orderBy: [{ updated_at: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const reservations = approved.length ? await tx.paymentBatchItem.findMany({
|
||||
where: { entity_id: { in: approved.map((form) => form.id) }, payment_batch: { entity_type: "reimbursement", status: { not: "cancelled" } } },
|
||||
select: { entity_id: true },
|
||||
}) : [];
|
||||
const reserved = new Set(reservations.map((item) => item.entity_id));
|
||||
for (const form of approved) {
|
||||
if (reserved.has(form.id) || !await departmentEnabled(form.department_id)) continue;
|
||||
items.push({ key: `reimbursement-batch:${form.id}`, kind: "reimbursement_batch", reference: form.form_number,
|
||||
title: "报销待组建付款批次", nextStep: "核对付款资料后组批,资料缺项由付款页说明", ownerLabel: "财务",
|
||||
href: `/reimbursements/${form.id}`, mobileHref: `/mobile/reimbursements/${form.id}` });
|
||||
}
|
||||
}
|
||||
const batches = await tx.paymentBatch.findMany({
|
||||
where: { entity_type: "reimbursement", OR: [
|
||||
...(canReview ? [{ status: "draft", reviewed_at: null, created_by: { not: employee.id } }] : []),
|
||||
...(canManage ? [{ status: { in: ["reviewed", "failed"] } }] : []),
|
||||
] },
|
||||
select: { id: true, batch_no: true, status: true, created_by: true, payment_order_export_count: true, default_payment_channel: true,
|
||||
items: { select: { entity_id: true, status: true, payment_channel_override: true } },
|
||||
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
|
||||
},
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
});
|
||||
const sourceIds = [...new Set(batches.flatMap((batch) => batch.items.map((item) => item.entity_id)))];
|
||||
const sources = sourceIds.length ? await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: sourceIds }, deleted_at: null }, select: { id: true, department_id: true },
|
||||
}) : [];
|
||||
const sourceDepartments = new Map(sources.map((source) => [source.id, source.department_id]));
|
||||
for (const batch of batches) {
|
||||
if (!batch.items.length || !batch.created_by) continue;
|
||||
if (batch.status === "draft" && batch.items.some((item) => item.status !== "pending")) continue;
|
||||
const readable = await Promise.all(batch.items.map((item) => sourceDepartments.has(item.entity_id) && departmentEnabled(sourceDepartments.get(item.entity_id))));
|
||||
if (readable.some((enabled) => !enabled)) continue;
|
||||
const review = batch.status === "draft";
|
||||
const semantics = buildPaymentProcessingSemantics({ status: batch.status,
|
||||
requiresBankReceipt: batch.items.some((item) => ["pending", "failed"].includes(item.status)
|
||||
&& (item.payment_channel_override ?? batch.default_payment_channel ?? "bank") === "bank"),
|
||||
paymentOrderExportCount: batch.payment_order_export_count, receiptCount: batch._count.receipt_attachments,
|
||||
});
|
||||
items.push({ key: `payment-${review ? "review" : "execute"}:${batch.id}`, kind: review ? "payment_review" : "payment_execute", reference: batch.batch_no,
|
||||
title: review ? "付款批次待复核" : "付款与回单待处理", nextStep: (review ? semantics.review : semantics.payment).detail,
|
||||
ownerLabel: review ? "非制单复核人" : "财务", href: `/payment-batches?id=${batch.id}`, mobileHref: `/mobile/payment-batches?id=${batch.id}` });
|
||||
}
|
||||
const unique = [...new Map(items.map((item) => [item.key, item])).values()];
|
||||
return { total: unique.length, items: unique };
|
||||
}, { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
|
||||
}
|
||||
|
||||
module.exports = { loadReimbursementPaymentHandoffs };
|
||||
在新工单中引用
屏蔽一个用户