收口付款批次创建与复核(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
@@ -422,10 +422,10 @@ function paymentBatchEntityTypeLabel(value) {
|
||||
}
|
||||
|
||||
function paymentBatchStatusLabel(value) {
|
||||
if (value === "draft") return "草稿";
|
||||
if (value === "reviewed") return "已复核";
|
||||
if (value === "executed") return "已执行";
|
||||
if (value === "failed") return "执行失败";
|
||||
if (value === "draft") return "待付款复核";
|
||||
if (value === "reviewed") return "待财务付款";
|
||||
if (value === "executed") return "已付款";
|
||||
if (value === "failed") return "付款登记失败";
|
||||
if (value === "cancelled") return "已取消";
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { parseDateOnlyString } = require("../utils/date");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { createNotificationsWithRecords } = require("../services/notifications");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
@@ -50,7 +50,7 @@ const CANDIDATES_QUERY_SCHEMA = z.object({
|
||||
|
||||
const CREATE_SCHEMA = z.object({
|
||||
entityType: ENTITY_TYPE_SCHEMA,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
entityIds: z.array(UUID_SCHEMA).min(1).max(200),
|
||||
defaultPaymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
defaultPaymentChannel: PAYMENT_CHANNEL_SCHEMA.optional(),
|
||||
@@ -132,7 +132,8 @@ function mapReceiptAttachment(item) {
|
||||
}
|
||||
|
||||
function resolveItemPaymentChannel(batch, item) {
|
||||
return item.payment_channel_override ?? batch.default_payment_channel ?? null;
|
||||
return item.payment_channel_override ?? batch.default_payment_channel ??
|
||||
(batch.entity_type === "reimbursement" ? "bank" : null);
|
||||
}
|
||||
|
||||
async function ensureBankReceiptForExecutableBatch(tx, batch) {
|
||||
@@ -270,9 +271,15 @@ async function resolvePaymentReviewRecipientIds(tx, makerId) {
|
||||
},
|
||||
});
|
||||
|
||||
return candidates
|
||||
.filter((employee) => employeeHasRolePermission(employee, "payment.batch.review"))
|
||||
.map((employee) => employee.id);
|
||||
const eligible = candidates.filter((employee) =>
|
||||
employee.id !== makerId && employeeHasRolePermission(employee, "payment.batch.review"),
|
||||
);
|
||||
const chiefExecutive = eligible.find((employee) => roleCodesOf(employee).includes("ceo"));
|
||||
if (chiefExecutive) {
|
||||
return [chiefExecutive.id];
|
||||
}
|
||||
const manager = eligible.find((employee) => roleCodesOf(employee).includes("manager"));
|
||||
return manager ? [manager.id] : [];
|
||||
}
|
||||
|
||||
async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) {
|
||||
@@ -368,6 +375,10 @@ function buildBatchNo() {
|
||||
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`;
|
||||
}
|
||||
|
||||
function buildDefaultBatchName(timeZone) {
|
||||
return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`;
|
||||
}
|
||||
|
||||
function summarizeBatchRisk(batch) {
|
||||
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
|
||||
const totalAmount = toMoneyString(totalAmountValue);
|
||||
@@ -550,7 +561,7 @@ function mapBatch(batch, openRiskFlags = []) {
|
||||
name: batch.name,
|
||||
status: batch.status,
|
||||
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
defaultPaymentChannel: batch.default_payment_channel ?? null,
|
||||
defaultPaymentChannel: batch.default_payment_channel ?? (batch.entity_type === "reimbursement" ? "bank" : null),
|
||||
voucherPrefix: batch.voucher_prefix ?? null,
|
||||
totalAmount: batchRisk.totalAmount,
|
||||
highRisk: batchRisk.highRisk,
|
||||
@@ -606,7 +617,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
|
||||
return items.map(buildPersistedRiskFlag);
|
||||
}
|
||||
|
||||
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) {
|
||||
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request, timeZone }) {
|
||||
const current = await tx.reimbursementForm.findUnique({
|
||||
where: { id: item.entity_id },
|
||||
select: {
|
||||
@@ -641,8 +652,8 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index, re
|
||||
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
|
||||
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
|
||||
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
|
||||
?? null;
|
||||
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
|
||||
?? currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? "bank";
|
||||
|
||||
const now = new Date();
|
||||
const archivedResult = await tx.invoicePool.updateMany({
|
||||
@@ -823,7 +834,7 @@ async function processPaidReimbursementFollowUps({ fastify, request, reimburseme
|
||||
}
|
||||
}
|
||||
|
||||
async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) {
|
||||
async function executeTravelOrderPayment(tx, { batch, item, actorId, index, timeZone }) {
|
||||
const current = await tx.travelOrder.findUnique({
|
||||
where: { id: item.entity_id },
|
||||
select: {
|
||||
@@ -855,7 +866,7 @@ async function executeTravelOrderPayment(tx, { batch, item, actorId, index }) {
|
||||
(batch.voucher_prefix ? `${batch.voucher_prefix}-${String(index + 1).padStart(3, "0")}` : null);
|
||||
const paymentDate = item.payment_date_override?.toISOString?.()?.slice(0, 10)
|
||||
?? batch.default_payment_date?.toISOString?.()?.slice(0, 10)
|
||||
?? null;
|
||||
?? currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
const paymentChannel = item.payment_channel_override ?? batch.default_payment_channel ?? null;
|
||||
const matchedTransaction = await tx.cardTransaction.findFirst({
|
||||
where: {
|
||||
@@ -1199,14 +1210,19 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw buildValidationError({ entityIds: "contains_active_payment_batch" }, 409);
|
||||
}
|
||||
|
||||
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
|
||||
if (!recipientEmployeeIds.length) {
|
||||
throw buildValidationError({ review: "no_eligible_reviewer" }, 409);
|
||||
}
|
||||
|
||||
const batch = await tx.paymentBatch.create({
|
||||
data: {
|
||||
batch_no: buildBatchNo(),
|
||||
entity_type: body.entityType,
|
||||
name: body.name,
|
||||
name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone),
|
||||
status: "draft",
|
||||
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
|
||||
default_payment_channel: body.defaultPaymentChannel ?? null,
|
||||
default_payment_channel: body.defaultPaymentChannel ?? "bank",
|
||||
voucher_prefix: normalizeNullableString(body.voucherPrefix),
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
@@ -1243,7 +1259,6 @@ async function paymentBatchesRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
|
||||
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
|
||||
recipientEmployeeIds,
|
||||
type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
|
||||
@@ -1850,12 +1865,19 @@ async function paymentBatchesRoutes(fastify) {
|
||||
actorId: request.employee.id,
|
||||
index,
|
||||
request,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
});
|
||||
if (!paymentResult.idempotent) {
|
||||
successfulReimbursementIds.push(item.entity_id);
|
||||
}
|
||||
} else {
|
||||
await executeTravelOrderPayment(tx, { batch, item, actorId: request.employee.id, index });
|
||||
await executeTravelOrderPayment(tx, {
|
||||
batch,
|
||||
item,
|
||||
actorId: request.employee.id,
|
||||
index,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.paymentBatchItem.update({
|
||||
|
||||
@@ -6,6 +6,7 @@ const Fastify = require("fastify");
|
||||
const multipart = require("@fastify/multipart");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const { currentDateString } = require("../../../src/utils/date");
|
||||
const featureFlags = require("../../../src/services/featureFlags");
|
||||
const notifications = require("../../../src/services/notifications");
|
||||
const email = require("../../../src/services/email");
|
||||
@@ -1033,8 +1034,60 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates no payment review notification when no eligible non-maker reviewer exists", async () => {
|
||||
const { app } = await buildApp({
|
||||
it("generates the batch title and bank defaults when the client only selects reimbursements", async () => {
|
||||
const { app, tx } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
entityType: "reimbursement",
|
||||
entityIds: [REIMBURSEMENT_ID],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
name: `${currentDateString("Asia/Shanghai")} 付款批次`,
|
||||
default_payment_date: null,
|
||||
default_payment_channel: "bank",
|
||||
voucher_prefix: null,
|
||||
status: "draft",
|
||||
}),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("notifies one active chief executive before falling back to managers", async () => {
|
||||
const chiefExecutiveId = "99999999-1111-4111-8111-111111111111";
|
||||
const { app, tx } = await buildApp({
|
||||
employees: [
|
||||
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
|
||||
{ id: chiefExecutiveId, employee_no: "CEO001", status: "active", roles: [{ role_code: "ceo" }] },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({ recipientEmployeeIds: [chiefExecutiveId] }),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects creation without an eligible non-maker reviewer and leaves no side effects", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
employees: [
|
||||
{ id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
|
||||
],
|
||||
@@ -1046,21 +1099,39 @@ describe("payment batch reimbursement routes", () => {
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
entityType: "reimbursement",
|
||||
name: "无人复核批次",
|
||||
entityIds: [REIMBURSEMENT_ID],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ recipientEmployeeIds: [] }),
|
||||
);
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.review).toBe("no_eligible_reviewer");
|
||||
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
|
||||
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses the company-local registration date when no payment date was entered", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
batch: { default_payment_date: null },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/execute`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(state.reimbursement.payment_date?.toISOString().slice(0, 10)).toBe(currentDateString("Asia/Shanghai"));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps a created batch when payment review notification delivery fails", async () => {
|
||||
createNotificationsWithRecordsMock.mockResolvedValueOnce([
|
||||
{ id: "notif-review-failed", recipient_employee_id: REVIEWER_ID },
|
||||
@@ -1197,6 +1268,35 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("treats a legacy reimbursement batch without a channel as bank without rewriting it", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
batch: { default_payment_channel: null },
|
||||
receiptAttachments: [],
|
||||
});
|
||||
|
||||
const detail = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
expect(detail.statusCode).toBe(200);
|
||||
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
|
||||
expect(state.batch.default_payment_channel).toBeNull();
|
||||
|
||||
const execute = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/execute`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {},
|
||||
});
|
||||
expect(execute.statusCode).toBe(409);
|
||||
expect(execute.json().error.details.receiptAttachments).toBe("bank_receipt_required");
|
||||
expect(state.batch.default_payment_channel).toBeNull();
|
||||
expect(state.reimbursement.status).toBe("approved");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects execution after the source department leaves the rollout with zero payment side effects", async () => {
|
||||
const { app, state, tx } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] });
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户