feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)

这个提交包含在:
2026-02-07 21:54:29 +08:00
父节点 7ea25e9d72
当前提交 c4df9a419a
共修改 23 个文件,包含 2102 行新增和 70 行删除
+127
查看文件
@@ -0,0 +1,127 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
actorId: z.string().uuid().optional(),
action: z.string().min(1).max(100).optional(),
targetType: z.string().min(1).max(100).optional(),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
});
function parseQuery(query) {
const parsed = QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const page = parsed.data.page ?? 1;
const pageSize = parsed.data.pageSize ?? 20;
const dateFrom = parsed.data.dateFrom ? new Date(parsed.data.dateFrom) : null;
const dateTo = parsed.data.dateTo ? new Date(parsed.data.dateTo) : null;
if (dateFrom && dateTo && dateFrom > dateTo) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { dateTo: "must_be_after_dateFrom" },
});
}
return {
page,
pageSize,
actorId: parsed.data.actorId,
action: parsed.data.action,
targetType: parsed.data.targetType,
dateFrom,
dateTo,
};
}
async function auditRoutes(fastify) {
fastify.get(
"/audit-logs",
{
preHandler: fastify.requirePermission("system.audit.view"),
},
async (request, reply) => {
const query = parseQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.actorId ? { actor_id: query.actorId } : {}),
...(query.action ? { action: query.action } : {}),
...(query.targetType ? { target_type: query.targetType } : {}),
...(query.dateFrom || query.dateTo
? {
created_at: {
...(query.dateFrom ? { gte: query.dateFrom } : {}),
...(query.dateTo ? { lte: query.dateTo } : {}),
},
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.auditLog.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
request_id: true,
actor_id: true,
action: true,
target_type: true,
target_id: true,
ip_address: true,
user_agent: true,
extra: true,
created_at: true,
actor: {
select: {
id: true,
name: true,
email: true,
},
},
},
}),
fastify.prisma.auditLog.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map((item) => ({
id: item.id,
requestId: item.request_id,
actorId: item.actor_id,
actorName: item.actor?.name ?? "",
actorEmail: item.actor?.email ?? "",
action: item.action,
targetType: item.target_type,
targetId: item.target_id,
ipAddress: item.ip_address,
userAgent: item.user_agent,
extra: item.extra,
createdAt: item.created_at.toISOString(),
})),
});
},
);
}
module.exports = auditRoutes;
+297
查看文件
@@ -0,0 +1,297 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { computeDelegationStatus } = require("../utils/delegation");
const UUID_SCHEMA = z.string().uuid();
const CREATE_DELEGATION_SCHEMA = z.object({
delegateEmployeeId: z.string().uuid(),
scope: z.enum(["reimbursement_submit", "all"]).optional(),
validFrom: z.string().datetime().optional(),
validTo: z.string().datetime().optional(),
});
const QUERY_STATUS_SCHEMA = z.object({
status: z.enum(["active", "scheduled", "expired", "revoked", "all"]).optional(),
});
function parseDelegationId(id) {
const parsed = UUID_SCHEMA.safeParse(id);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
function parseCreateBody(body) {
const parsed = CREATE_DELEGATION_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const validFrom = parsed.data.validFrom ? new Date(parsed.data.validFrom) : new Date();
const validTo = parsed.data.validTo ? new Date(parsed.data.validTo) : null;
if (Number.isNaN(validFrom.getTime())) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { validFrom: "invalid_datetime" },
});
}
if (validTo && Number.isNaN(validTo.getTime())) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { validTo: "invalid_datetime" },
});
}
if (validTo && validTo <= validFrom) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { validTo: "must_be_after_validFrom" },
});
}
return {
delegateEmployeeId: parsed.data.delegateEmployeeId,
scope: parsed.data.scope ?? "all",
validFrom,
validTo,
};
}
function parseStatusQuery(query) {
const parsed = QUERY_STATUS_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data.status ?? "active";
}
function matchesStatusFilter(record, status, now) {
if (status === "all") {
return true;
}
return computeDelegationStatus(record, now) === status;
}
function toResponseItem(record, now) {
return {
id: record.id,
principalEmployeeId: record.principal_employee_id,
delegateEmployeeId: record.delegate_employee_id,
scope: record.scope,
validFrom: record.valid_from.toISOString(),
validTo: record.valid_to ? record.valid_to.toISOString() : null,
revokedAt: record.revoked_at ? record.revoked_at.toISOString() : null,
createdAt: record.created_at.toISOString(),
status: computeDelegationStatus(record, now),
};
}
async function delegationRoutes(fastify) {
fastify.post(
"/delegations",
{
preHandler: fastify.requirePermission("expense.proxy_manage"),
},
async (request, reply) => {
const input = parseCreateBody(request.body);
if (input.delegateEmployeeId === request.employee.id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { delegateEmployeeId: "self_not_allowed" },
});
}
const delegateEmployee = await fastify.prisma.employee.findUnique({
where: { id: input.delegateEmployeeId },
select: {
id: true,
status: true,
},
});
if (!delegateEmployee || delegateEmployee.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { delegateEmployeeId: "invalid_delegate" },
});
}
const delegation = await fastify.prisma.$transaction(async (tx) => {
const created = await tx.delegationGrant.create({
data: {
principal_employee_id: request.employee.id,
delegate_employee_id: input.delegateEmployeeId,
scope: input.scope,
valid_from: input.validFrom,
valid_to: input.validTo,
created_by: request.employee.id,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DELEGATION_CREATE",
targetType: "delegation_grant",
targetId: created.id,
extra: {
principalEmployeeId: request.employee.id,
delegateEmployeeId: input.delegateEmployeeId,
scope: input.scope,
},
}),
});
return created;
});
return sendData(reply, request, {
delegation: toResponseItem(delegation, new Date()),
});
},
);
fastify.get(
"/delegations/mine",
{
preHandler: fastify.requirePermission("expense.proxy_manage"),
},
async (request, reply) => {
const status = parseStatusQuery(request.query);
const now = new Date();
const [initiatedRaw, receivedRaw] = await Promise.all([
fastify.prisma.delegationGrant.findMany({
where: {
principal_employee_id: request.employee.id,
},
orderBy: { created_at: "desc" },
}),
fastify.prisma.delegationGrant.findMany({
where: {
delegate_employee_id: request.employee.id,
},
orderBy: { created_at: "desc" },
}),
]);
const initiated = initiatedRaw
.filter((item) => matchesStatusFilter(item, status, now))
.map((item) => toResponseItem(item, now));
const received = receivedRaw
.filter((item) => matchesStatusFilter(item, status, now))
.map((item) => toResponseItem(item, now));
return sendData(reply, request, {
status,
initiated,
received,
});
},
);
fastify.post(
"/delegations/:id/revoke",
{
preHandler: fastify.requirePermission("expense.proxy_manage"),
},
async (request, reply) => {
const delegationId = parseDelegationId(request.params?.id);
const now = new Date();
const delegation = await fastify.prisma.delegationGrant.findUnique({
where: { id: delegationId },
});
if (!delegation) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const canRevoke =
delegation.principal_employee_id === request.employee.id ||
fastify.hasPermission(request.employee, "system.settings");
if (!canRevoke) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (delegation.revoked_at) {
return sendData(reply, request, {
delegation: toResponseItem(delegation, now),
outcome: "already_revoked",
});
}
const revoked = await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.delegationGrant.update({
where: { id: delegationId },
data: {
revoked_at: now,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "DELEGATION_REVOKE",
targetType: "delegation_grant",
targetId: delegationId,
extra: {
principalEmployeeId: delegation.principal_employee_id,
delegateEmployeeId: delegation.delegate_employee_id,
},
}),
});
return updated;
});
return sendData(reply, request, {
delegation: toResponseItem(revoked, now),
outcome: "revoked",
});
},
);
}
module.exports = delegationRoutes;
+92 -1
查看文件
@@ -10,6 +10,7 @@ const { compactDateString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { signPayload, verifyToken } = require("../utils/signedUrl");
const { toMoneyString } = require("../utils/workflow");
const { isOcrEnabledForType } = require("../services/ocr");
const MIME_ALLOWLIST = new Set([
"application/pdf",
@@ -258,6 +259,8 @@ async function invoicePoolRoutes(fastify) {
await fs.mkdir(path.dirname(absoluteStoragePath), { recursive: true });
await fs.writeFile(absoluteStoragePath, fileBuffer);
const status = isOcrEnabledForType(fastify.config, ocrType) ? "pending_ocr" : "available";
const invoice = await fastify.prisma.invoicePool.create({
data: {
owner_employee_id: ownerEmployeeId,
@@ -268,7 +271,7 @@ async function invoicePoolRoutes(fastify) {
file_size: fileSize,
file_sha256: fileSha256,
ocr_type: ocrType,
status: "available",
status,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -287,6 +290,94 @@ async function invoicePoolRoutes(fastify) {
},
);
fastify.get(
"/invoice-pool/:id",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
const invoice = await fastify.prisma.invoicePool.findUnique({
where: { id: parsedInvoiceId.data },
select: {
id: true,
status: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
created_at: true,
updated_at: true,
linked_reimbursement: {
select: {
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let canAccess = ensureInvoiceReadableByUser(request, invoice);
if (!canAccess && invoice.linked_reimbursement_id) {
const pendingTask = await fastify.prisma.workflowTask.findFirst({
where: {
status: "pending",
workflow_instance: {
entity_type: "reimbursement",
entity_id: invoice.linked_reimbursement_id,
current_status: "pending",
},
OR: [
{ assignee_id: request.employee.id },
{ assignee_role: { in: request.employee.roleCodes } },
],
},
select: { id: true },
});
canAccess = Boolean(pendingTask);
}
if (!canAccess) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
return sendData(reply, request, normalizeInvoiceResponse(invoice));
},
);
fastify.post(
"/invoice-pool/:id/signed-url",
{
+82 -1
查看文件
@@ -9,6 +9,11 @@ const {
} = require("../utils/workflow");
const UUID_SCHEMA = z.string().uuid();
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(),
});
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
@@ -74,6 +79,32 @@ function parseReimbursementId(id) {
return parsed.data;
}
function parsePayBody(body) {
const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function toDateOnlyString(dateValue) {
if (!dateValue) {
return null;
}
if (typeof dateValue === "string") {
return dateValue.slice(0, 10);
}
return new Date(dateValue).toISOString().slice(0, 10);
}
function ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
@@ -655,11 +686,12 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const payInput = parsePayBody(request.body);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status
SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -683,6 +715,27 @@ async function reimbursementRoutes(fastify) {
});
}
const existingPaymentDate = toDateOnlyString(reimbursement.payment_date);
if (reimbursement.status === "paid") {
const hasVoucherConflict =
payInput.paymentVoucherNo != null &&
payInput.paymentVoucherNo !== (reimbursement.payment_voucher_no ?? null);
const hasPaymentDateConflict =
payInput.paymentDate != null && payInput.paymentDate !== existingPaymentDate;
const hasChannelConflict =
payInput.paymentChannel != null &&
payInput.paymentChannel !== (reimbursement.payment_channel ?? null);
if (hasVoucherConflict || hasPaymentDateConflict || hasChannelConflict) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { payment: "already_paid_conflict" },
});
}
}
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
where: {
@@ -702,6 +755,11 @@ async function reimbursementRoutes(fastify) {
where: { id: reimbursementId },
data: {
status: "paid",
payment_voucher_no: payInput.paymentVoucherNo ?? null,
payment_date: payInput.paymentDate ? parseDateOnlyString(payInput.paymentDate) : null,
payment_channel: payInput.paymentChannel ?? null,
paid_at: now,
paid_by: request.employee.id,
updated_by: request.employee.id,
},
});
@@ -713,6 +771,29 @@ async function reimbursementRoutes(fastify) {
reimbursementStatus: "paid",
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
archivedInvoiceCount: archivedResult.count,
payment: {
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,
paidAt:
reimbursement.status === "paid"
? reimbursement.paid_at?.toISOString?.() ?? null
: now.toISOString(),
paidBy:
reimbursement.status === "paid"
? reimbursement.paid_by ?? null
: request.employee.id,
idempotent: reimbursement.status === "paid",
},
};
});
+98 -32
查看文件
@@ -3,6 +3,10 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
const {
findActiveDelegationForDelegate,
listActiveDelegationPrincipals,
} = require("../utils/delegation");
const TASK_ID_SCHEMA = z.string().uuid();
const REQUIRED_COMMENT_SCHEMA = z.object({
@@ -98,20 +102,54 @@ function ensurePendingTask(task) {
}
}
function ensureTaskAssignableToCurrentEmployee(task, request) {
async function ensureTaskAssignableToCurrentEmployee(tx, task, request) {
const matchesAssigneeId = Boolean(task.assignee_id && task.assignee_id === request.employee.id);
const matchesAssigneeRole = Boolean(
task.assignee_role && request.employee.roleCodes.includes(task.assignee_role),
);
const isUnassigned = !task.assignee_id && !task.assignee_role;
if (!(matchesAssigneeId || matchesAssigneeRole || isUnassigned)) {
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
if (matchesAssigneeId || matchesAssigneeRole || isUnassigned) {
return {
delegated: false,
delegationId: null,
principalId: null,
};
}
if (task.assignee_id) {
const delegation = await findActiveDelegationForDelegate(tx, {
principalEmployeeId: task.assignee_id,
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
if (delegation) {
return {
delegated: true,
delegationId: delegation.id,
principalId: delegation.principal_employee_id,
};
}
}
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
}
function withDelegationPayload(payload, assignment, delegateId) {
const nextPayload = payload ? { ...payload } : {};
if (assignment?.delegated) {
nextPayload.delegation_id = assignment.delegationId;
nextPayload.principal_id = assignment.principalId;
nextPayload.delegate_id = delegateId;
}
return nextPayload;
}
async function loadWorkflowInstanceForTask(tx, instanceId) {
@@ -253,11 +291,19 @@ async function workflowRoutes(fastify) {
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters];
if (delegatedPrincipalIds.length) {
assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } });
}
const tasks = await fastify.prisma.workflowTask.findMany({
where: {
status,
OR: [{ assignee_id: request.employee.id }, ...roleFilters],
OR: assigneeFilters,
},
orderBy: { created_at: "desc" },
select: {
@@ -299,7 +345,7 @@ async function workflowRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const steps = getWorkflowStepsSnapshot(workflowInstance);
@@ -367,10 +413,14 @@ async function workflowRoutes(fastify) {
task_id: taskId,
actor_id: request.employee.id,
action: "approve",
payload: {
from_step_id: task.step_id,
to_step_id: actionable.step.id,
},
payload: withDelegationPayload(
{
from_step_id: task.step_id,
to_step_id: actionable.step.id,
},
assignment,
request.employee.id,
),
},
});
@@ -407,10 +457,14 @@ async function workflowRoutes(fastify) {
task_id: taskId,
actor_id: request.employee.id,
action: "approve",
payload: {
from_step_id: task.step_id,
terminal: true,
},
payload: withDelegationPayload(
{
from_step_id: task.step_id,
terminal: true,
},
assignment,
request.employee.id,
),
},
});
@@ -440,7 +494,7 @@ async function workflowRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const now = new Date();
@@ -482,9 +536,13 @@ async function workflowRoutes(fastify) {
actor_id: request.employee.id,
action: "reject",
comment,
payload: {
from_step_id: task.step_id,
},
payload: withDelegationPayload(
{
from_step_id: task.step_id,
},
assignment,
request.employee.id,
),
},
});
@@ -513,7 +571,7 @@ async function workflowRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const now = new Date();
@@ -555,9 +613,13 @@ async function workflowRoutes(fastify) {
actor_id: request.employee.id,
action: "return",
comment,
payload: {
from_step_id: task.step_id,
},
payload: withDelegationPayload(
{
from_step_id: task.step_id,
},
assignment,
request.employee.id,
),
},
});
@@ -586,7 +648,7 @@ async function workflowRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const task = await lockWorkflowTask(tx, taskId);
ensurePendingTask(task);
ensureTaskAssignableToCurrentEmployee(task, request);
const assignment = await ensureTaskAssignableToCurrentEmployee(tx, task, request);
const workflowInstance = await loadWorkflowInstanceForTask(tx, task.instance_id);
const transferCount = await ensureTransferChainNotExceeded(tx, taskId);
@@ -630,12 +692,16 @@ async function workflowRoutes(fastify) {
actor_id: request.employee.id,
action: "transfer",
comment: reason,
payload: {
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
transfer_count: transferCount + 1,
},
payload: withDelegationPayload(
{
from_approver_id: request.employee.id,
to_approver_id: toApproverId,
reason,
transfer_count: transferCount + 1,
},
assignment,
request.employee.id,
),
},
});