feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)
这个提交包含在:
@@ -16,6 +16,10 @@ const meRoutes = require("./routes/me");
|
||||
const reimbursementRoutes = require("./routes/reimbursements");
|
||||
const invoicePoolRoutes = require("./routes/invoicePool");
|
||||
const workflowRoutes = require("./routes/workflow");
|
||||
const delegationRoutes = require("./routes/delegations");
|
||||
const auditRoutes = require("./routes/audit");
|
||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||
const { startOcrJob } = require("./jobs/ocrJob");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
|
||||
async function buildApp() {
|
||||
@@ -114,6 +118,11 @@ async function buildApp() {
|
||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
||||
await app.register(workflowRoutes, { prefix: "/api" });
|
||||
await app.register(delegationRoutes, { prefix: "/api" });
|
||||
await app.register(auditRoutes, { prefix: "/api" });
|
||||
|
||||
startDelegationRevokeJob(app);
|
||||
startOcrJob(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,19 @@ function getConfig() {
|
||||
storageDir: path.resolve(serverRoot, storageDir),
|
||||
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
||||
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
||||
|
||||
delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *",
|
||||
|
||||
ocrProvider: process.env.OCR_PROVIDER ?? "off",
|
||||
ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10),
|
||||
ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300),
|
||||
ocrPollCron: process.env.OCR_POLL_CRON ?? "* * * * *",
|
||||
ocrMaxAttempts: Number(process.env.OCR_MAX_ATTEMPTS ?? 3),
|
||||
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
|
||||
|
||||
tencentSecretId: process.env.TENCENT_SECRET_ID ?? "",
|
||||
tencentSecretKey: process.env.TENCENT_SECRET_KEY ?? "",
|
||||
tencentOcrRegion: process.env.TENCENT_OCR_REGION ?? "ap-beijing",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.view_all",
|
||||
"expense.finance.override_duplicate",
|
||||
"system.settings",
|
||||
"system.audit.view",
|
||||
],
|
||||
administration: ["expense.proxy_submit", "expense.view_own"],
|
||||
hr: ["system.users", "expense.view_all"],
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
const cron = require("node-cron");
|
||||
|
||||
async function revokeExpiredDelegations(prisma, logger, options = {}) {
|
||||
const batchSize = options.batchSize ?? 200;
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const now = new Date();
|
||||
const expired = await prisma.delegationGrant.findMany({
|
||||
where: {
|
||||
revoked_at: null,
|
||||
valid_to: {
|
||||
not: null,
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
orderBy: { valid_to: "asc" },
|
||||
take: batchSize,
|
||||
select: {
|
||||
id: true,
|
||||
principal_employee_id: true,
|
||||
delegate_employee_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!expired.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const ids = expired.map((item) => item.id);
|
||||
await tx.delegationGrant.updateMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
revoked_at: null,
|
||||
},
|
||||
data: {
|
||||
revoked_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.createMany({
|
||||
data: expired.map((item) => ({
|
||||
request_id: `job:delegation-revoke:${now.getTime()}:${item.id}`,
|
||||
actor_id: item.principal_employee_id,
|
||||
action: "DELEGATION_AUTO_REVOKED",
|
||||
target_type: "delegation_grant",
|
||||
target_id: item.id,
|
||||
ip_address: null,
|
||||
user_agent: "system/node-cron",
|
||||
extra: {
|
||||
principalEmployeeId: item.principal_employee_id,
|
||||
delegateEmployeeId: item.delegate_employee_id,
|
||||
},
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
total += expired.length;
|
||||
}
|
||||
|
||||
if (total > 0) {
|
||||
logger.info({ revoked: total }, "Expired delegations auto-revoked");
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function startDelegationRevokeJob(fastify) {
|
||||
let running = false;
|
||||
|
||||
const task = cron.schedule(fastify.config.delegationRevokeCron, async () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
await revokeExpiredDelegations(fastify.prisma, fastify.log);
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error }, "Delegation revoke job failed");
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.addHook("onClose", async () => {
|
||||
task.stop();
|
||||
task.destroy();
|
||||
});
|
||||
|
||||
fastify.log.info(
|
||||
{ cron: fastify.config.delegationRevokeCron },
|
||||
"Delegation revoke job scheduled",
|
||||
);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
revokeExpiredDelegations,
|
||||
startDelegationRevokeJob,
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const cron = require("node-cron");
|
||||
|
||||
const { runOcrForInvoice } = require("../services/ocr");
|
||||
|
||||
const OCR_DUPLICATE_ERROR = "duplicate_invoice_code_number";
|
||||
|
||||
function trimErrorMessage(error) {
|
||||
const message = error?.message ?? "ocr_failed";
|
||||
if (message.length > 500) {
|
||||
return message.slice(0, 500);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function toDateOnly(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${value}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function toMoney(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return num.toFixed(2);
|
||||
}
|
||||
|
||||
async function claimPendingInvoices(prisma, config, workerId) {
|
||||
const batchSize = Math.max(1, Number(config.ocrBatchSize || 10));
|
||||
const lockTtlSeconds = Math.max(60, Number(config.ocrLockTtlSeconds || 300));
|
||||
|
||||
return prisma.$queryRaw`
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM "invoice_pool"
|
||||
WHERE status = 'pending_ocr'
|
||||
AND (ocr_locked_at IS NULL OR ocr_locked_at < NOW() - make_interval(secs => ${lockTtlSeconds}))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ${batchSize}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE "invoice_pool" AS i
|
||||
SET ocr_locked_at = NOW(),
|
||||
ocr_lock_owner = ${workerId}
|
||||
FROM candidates
|
||||
WHERE i.id = candidates.id
|
||||
RETURNING i.id, i.file_sha256, i.ocr_type, i.storage_path, i.ocr_attempts
|
||||
`;
|
||||
}
|
||||
|
||||
async function findDuplicateInvoice(prisma, invoiceId, invoiceCode, invoiceNumber) {
|
||||
if (!invoiceCode || !invoiceNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prisma.invoicePool.findFirst({
|
||||
where: {
|
||||
id: { not: invoiceId },
|
||||
invoice_code: invoiceCode,
|
||||
invoice_number: invoiceNumber,
|
||||
is_duplicate_override: false,
|
||||
OR: [
|
||||
{ status: "bound" },
|
||||
{
|
||||
status: "archived",
|
||||
archived_reason: "used_complete",
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getOrCreateOcrResult(prisma, config, claimedInvoice, imageBase64) {
|
||||
const where = {
|
||||
file_sha256_ocr_provider_ocr_type: {
|
||||
file_sha256: claimedInvoice.file_sha256,
|
||||
ocr_provider: "tencent",
|
||||
ocr_type: claimedInvoice.ocr_type,
|
||||
},
|
||||
};
|
||||
|
||||
const cached = await prisma.ocrResult.findUnique({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
result_json: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
return {
|
||||
ocrResultId: cached.id,
|
||||
normalized: cached.result_json,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = await runOcrForInvoice({
|
||||
config,
|
||||
ocrType: claimedInvoice.ocr_type,
|
||||
imageBase64,
|
||||
});
|
||||
|
||||
try {
|
||||
const created = await prisma.ocrResult.create({
|
||||
data: {
|
||||
file_sha256: claimedInvoice.file_sha256,
|
||||
ocr_provider: "tencent",
|
||||
ocr_type: claimedInvoice.ocr_type,
|
||||
result_json: normalized,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ocrResultId: created.id,
|
||||
normalized,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== "P2002") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const raced = await prisma.ocrResult.findUnique({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
result_json: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!raced) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
ocrResultId: raced.id,
|
||||
normalized: raced.result_json,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function markInvoiceAsDuplicate(prisma, invoiceId, normalized, ocrResultId) {
|
||||
const now = new Date();
|
||||
await prisma.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
status: "ocr_failed",
|
||||
ocr_result_id: ocrResultId,
|
||||
invoice_code: normalized.invoiceCode ?? null,
|
||||
invoice_number: normalized.invoiceNumber ?? null,
|
||||
invoice_date: normalized.invoiceDate ? new Date(toDateOnly(normalized.invoiceDate)) : null,
|
||||
amount: toMoney(normalized.amount),
|
||||
tax_amount: toMoney(normalized.taxAmount),
|
||||
total_amount: toMoney(normalized.totalAmount),
|
||||
seller_name: normalized.sellerName ?? null,
|
||||
buyer_name: normalized.buyerName ?? null,
|
||||
ocr_last_error: OCR_DUPLICATE_ERROR,
|
||||
ocr_completed_at: now,
|
||||
ocr_attempts: { increment: 1 },
|
||||
ocr_locked_at: null,
|
||||
ocr_lock_owner: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markInvoiceAsAvailable(prisma, invoiceId, normalized, ocrResultId) {
|
||||
const now = new Date();
|
||||
await prisma.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
status: "available",
|
||||
ocr_result_id: ocrResultId,
|
||||
invoice_code: normalized.invoiceCode ?? null,
|
||||
invoice_number: normalized.invoiceNumber ?? null,
|
||||
invoice_date: normalized.invoiceDate ? new Date(toDateOnly(normalized.invoiceDate)) : null,
|
||||
amount: toMoney(normalized.amount),
|
||||
tax_amount: toMoney(normalized.taxAmount),
|
||||
total_amount: toMoney(normalized.totalAmount),
|
||||
seller_name: normalized.sellerName ?? null,
|
||||
buyer_name: normalized.buyerName ?? null,
|
||||
ocr_last_error: null,
|
||||
ocr_completed_at: now,
|
||||
ocr_attempts: { increment: 1 },
|
||||
ocr_locked_at: null,
|
||||
ocr_lock_owner: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markInvoiceAsFailed(prisma, claimedInvoice, errorMessage, config) {
|
||||
const now = new Date();
|
||||
const nextAttempts = Number(claimedInvoice.ocr_attempts || 0) + 1;
|
||||
const isTerminal = nextAttempts >= config.ocrMaxAttempts;
|
||||
|
||||
await prisma.invoicePool.update({
|
||||
where: { id: claimedInvoice.id },
|
||||
data: {
|
||||
status: isTerminal ? "ocr_failed" : "pending_ocr",
|
||||
ocr_attempts: nextAttempts,
|
||||
ocr_last_error: errorMessage,
|
||||
ocr_completed_at: isTerminal ? now : null,
|
||||
ocr_locked_at: null,
|
||||
ocr_lock_owner: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, logger) {
|
||||
try {
|
||||
const absolutePath = path.resolve(config.storageDir, claimedInvoice.storage_path);
|
||||
const fileBuffer = await fs.readFile(absolutePath);
|
||||
const imageBase64 = fileBuffer.toString("base64");
|
||||
|
||||
const { ocrResultId, normalized } = await getOrCreateOcrResult(
|
||||
prisma,
|
||||
config,
|
||||
claimedInvoice,
|
||||
imageBase64,
|
||||
);
|
||||
|
||||
const duplicate = await findDuplicateInvoice(
|
||||
prisma,
|
||||
claimedInvoice.id,
|
||||
normalized.invoiceCode,
|
||||
normalized.invoiceNumber,
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
await markInvoiceAsDuplicate(prisma, claimedInvoice.id, normalized, ocrResultId);
|
||||
logger.warn(
|
||||
{
|
||||
invoiceId: claimedInvoice.id,
|
||||
duplicateInvoiceId: duplicate.id,
|
||||
},
|
||||
"OCR invoice marked as duplicate",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await markInvoiceAsAvailable(prisma, claimedInvoice.id, normalized, ocrResultId);
|
||||
} catch (error) {
|
||||
const message = trimErrorMessage(error);
|
||||
await markInvoiceAsFailed(prisma, claimedInvoice, message, config);
|
||||
logger.error({ err: error, invoiceId: claimedInvoice.id, workerId }, "OCR processing failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`) {
|
||||
const claimed = await claimPendingInvoices(prisma, config, workerId);
|
||||
if (!claimed.length) {
|
||||
return { claimed: 0 };
|
||||
}
|
||||
|
||||
for (const item of claimed) {
|
||||
await processClaimedInvoice(prisma, config, item, workerId, logger);
|
||||
}
|
||||
|
||||
return { claimed: claimed.length };
|
||||
}
|
||||
|
||||
function startOcrJob(fastify) {
|
||||
if (fastify.config.ocrProvider !== "tencent") {
|
||||
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
let running = false;
|
||||
const workerId = `ocr-worker:${process.pid}`;
|
||||
|
||||
const task = cron.schedule(fastify.config.ocrPollCron, async () => {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId);
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error }, "OCR job tick failed");
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.addHook("onClose", async () => {
|
||||
task.stop();
|
||||
task.destroy();
|
||||
});
|
||||
|
||||
fastify.log.info({ cron: fastify.config.ocrPollCron }, "OCR job scheduled");
|
||||
return task;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OCR_DUPLICATE_ERROR,
|
||||
claimPendingInvoices,
|
||||
runOcrOnce,
|
||||
startOcrJob,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { revokeExpiredDelegations } = require("../jobs/delegationRevokeJob");
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const count = await revokeExpiredDelegations(prisma, console);
|
||||
process.stdout.write(`revoked=${count}\n`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { getConfig } = require("../config");
|
||||
const { runOcrOnce } = require("../jobs/ocrJob");
|
||||
|
||||
(async () => {
|
||||
const config = getConfig();
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
if (config.ocrProvider !== "tencent") {
|
||||
process.stdout.write("ocr_provider_disabled\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runOcrOnce(prisma, config, console, `ocr-script:${process.pid}`);
|
||||
process.stdout.write(`claimed=${result.claimed}\n`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const { runTencentVatOcr } = require("./tencent");
|
||||
|
||||
function isOcrEnabledForType(config, ocrType) {
|
||||
return config.ocrProvider === "tencent" && ocrType === "vat";
|
||||
}
|
||||
|
||||
async function runOcrForInvoice({ config, ocrType, imageBase64 }) {
|
||||
if (config.ocrProvider !== "tencent") {
|
||||
throw new Error(`Unsupported OCR provider: ${config.ocrProvider}`);
|
||||
}
|
||||
|
||||
if (ocrType !== "vat") {
|
||||
throw new Error(`Unsupported OCR type: ${ocrType}`);
|
||||
}
|
||||
|
||||
return runTencentVatOcr(config, imageBase64);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isOcrEnabledForType,
|
||||
runOcrForInvoice,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
const tencentcloud = require("tencentcloud-sdk-nodejs");
|
||||
|
||||
function normalizeDate(value) {
|
||||
if (!value || typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const compact = value.trim();
|
||||
if (!compact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const replaced = compact.replace(/年|\//g, "-").replace(/月/g, "-").replace(/日/g, "");
|
||||
const matched = replaced.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day] = matched;
|
||||
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toNumberString(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripped = value.replace(/[,¥\s]/g, "").trim();
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const num = Number(stripped);
|
||||
if (!Number.isFinite(num)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return num.toFixed(2);
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function createTencentOcrClient(config) {
|
||||
if (!config.tencentSecretId || !config.tencentSecretKey) {
|
||||
throw new Error("Tencent OCR credentials are missing");
|
||||
}
|
||||
|
||||
const OcrClient = tencentcloud.ocr.v20181119.Client;
|
||||
return new OcrClient({
|
||||
credential: {
|
||||
secretId: config.tencentSecretId,
|
||||
secretKey: config.tencentSecretKey,
|
||||
},
|
||||
region: config.tencentOcrRegion,
|
||||
profile: {
|
||||
httpProfile: {
|
||||
timeout: config.ocrTimeoutMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeVatInvoiceResult(result) {
|
||||
return {
|
||||
invoiceCode: normalizeString(result?.InvoiceCode),
|
||||
invoiceNumber: normalizeString(result?.InvoiceNum),
|
||||
invoiceDate: normalizeDate(result?.InvoiceDate),
|
||||
amount: toNumberString(result?.AmountWithoutTax),
|
||||
taxAmount: toNumberString(result?.TaxAmount),
|
||||
totalAmount: toNumberString(result?.TotalAmount ?? result?.AmountInFiguers),
|
||||
sellerName: normalizeString(result?.SellerName),
|
||||
buyerName: normalizeString(result?.BuyerName),
|
||||
raw: result ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function runTencentVatOcr(config, imageBase64) {
|
||||
const client = createTencentOcrClient(config);
|
||||
const result = await client.VatInvoiceOCR({
|
||||
ImageBase64: imageBase64,
|
||||
});
|
||||
|
||||
return normalizeVatInvoiceResult(result);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runTencentVatOcr,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
function buildActiveDelegationWhere(now, delegateEmployeeId, allowedScopes) {
|
||||
return {
|
||||
delegate_employee_id: delegateEmployeeId,
|
||||
scope: { in: allowedScopes },
|
||||
revoked_at: null,
|
||||
valid_from: { lte: now },
|
||||
OR: [{ valid_to: null }, { valid_to: { gt: now } }],
|
||||
};
|
||||
}
|
||||
|
||||
async function findActiveDelegationForDelegate(
|
||||
tx,
|
||||
{ principalEmployeeId, delegateEmployeeId, allowedScopes = ["all"], now = new Date() },
|
||||
) {
|
||||
return tx.delegationGrant.findFirst({
|
||||
where: {
|
||||
principal_employee_id: principalEmployeeId,
|
||||
...buildActiveDelegationWhere(now, delegateEmployeeId, allowedScopes),
|
||||
},
|
||||
orderBy: { valid_from: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
principal_employee_id: true,
|
||||
delegate_employee_id: true,
|
||||
scope: true,
|
||||
valid_from: true,
|
||||
valid_to: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function listActiveDelegationPrincipals(
|
||||
tx,
|
||||
{ delegateEmployeeId, allowedScopes = ["all"], now = new Date() },
|
||||
) {
|
||||
const delegations = await tx.delegationGrant.findMany({
|
||||
where: buildActiveDelegationWhere(now, delegateEmployeeId, allowedScopes),
|
||||
select: {
|
||||
principal_employee_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Array.from(new Set(delegations.map((item) => item.principal_employee_id)));
|
||||
}
|
||||
|
||||
function computeDelegationStatus(delegation, now = new Date()) {
|
||||
if (delegation.revoked_at) {
|
||||
return "revoked";
|
||||
}
|
||||
|
||||
if (delegation.valid_to && delegation.valid_to <= now) {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
if (delegation.valid_from > now) {
|
||||
return "scheduled";
|
||||
}
|
||||
|
||||
return "active";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findActiveDelegationForDelegate,
|
||||
listActiveDelegationPrincipals,
|
||||
computeDelegationStatus,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户