feat(P19ERP1-3,P19ERP1-4): 实现ERP凭证导出与管理入口
这个提交包含在:
+492
-115
@@ -1,178 +1,550 @@
|
||||
const { z } = require("zod");
|
||||
const { z } = require('zod');
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { AppError } = require('../utils/AppError');
|
||||
const { sendData } = require('../utils/reply');
|
||||
const { buildAuditLogData } = require('../utils/audit');
|
||||
const {
|
||||
buildDataEnvelopeSchema,
|
||||
buildErrorEnvelopeSchema,
|
||||
} = require("../utils/openapi");
|
||||
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
|
||||
const { buildErpPayload } = require("../services/integrations/erp");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
|
||||
} = require('../utils/openapi');
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require('../services/featureFlags');
|
||||
const { runIntegrationOutboxOnce } = require('../jobs/integrationOutboxJob');
|
||||
const {
|
||||
ERP_MASTER_DATA_RESOURCES,
|
||||
listMasterDataSyncStates,
|
||||
normalizeRequestedResources,
|
||||
syncErpMasterData,
|
||||
} = require('../services/integrations/erpMasterData');
|
||||
const {
|
||||
buildReimbursementJournalPreview,
|
||||
enqueueReimbursementJournalExport,
|
||||
} = require('../services/integrations/erpJournal');
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
|
||||
const MASTER_DATA_SYNC_BODY_SCHEMA = z.object({
|
||||
resources: z.array(MASTER_DATA_RESOURCE_ENUM).max(3).optional(),
|
||||
});
|
||||
|
||||
const ERP_SYNC_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["outboxId", "status", "duplicated"],
|
||||
type: 'object',
|
||||
required: ['outboxId', 'status', 'duplicated'],
|
||||
properties: {
|
||||
outboxId: { type: "string", format: "uuid" },
|
||||
status: { type: "string" },
|
||||
duplicated: { type: "boolean" },
|
||||
outboxId: { type: 'string', format: 'uuid' },
|
||||
status: { type: 'string' },
|
||||
duplicated: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['outboxId', 'status', 'duplicated', 'requeued', 'eventType'],
|
||||
properties: {
|
||||
outboxId: { type: 'string', format: 'uuid' },
|
||||
status: { type: 'string' },
|
||||
duplicated: { type: 'boolean' },
|
||||
requeued: { type: 'boolean' },
|
||||
eventType: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const ERP_MASTER_DATA_STATUS_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['items', 'gate'],
|
||||
properties: {
|
||||
gate: {
|
||||
type: 'object',
|
||||
required: ['envEnabled', 'effectiveEnabled', 'effectiveReason', 'provider'],
|
||||
properties: {
|
||||
envEnabled: { type: 'boolean' },
|
||||
effectiveEnabled: { type: 'boolean' },
|
||||
effectiveReason: { type: 'string' },
|
||||
provider: { type: 'string' },
|
||||
masterDataEndpointConfigured: { type: 'boolean' },
|
||||
journalEndpointConfigured: { type: 'boolean' },
|
||||
masterDataSyncCron: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['resourceType', 'status'],
|
||||
properties: {
|
||||
resourceType: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
lastStartedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
lastFinishedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
lastSuccessAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
|
||||
lastError: { anyOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
lastResult: { anyOf: [{ type: 'object' }, { type: 'null' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function parseRunNow(query) {
|
||||
return (
|
||||
query?.runNow === '1' ||
|
||||
query?.runNow === 1 ||
|
||||
query?.runNow === true ||
|
||||
query?.runNow === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveErpGate(prisma, config) {
|
||||
const evaluation = await evaluateFeatureFlag({
|
||||
prisma,
|
||||
config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
|
||||
const envEnabled = Boolean(config.erpSyncEnabled);
|
||||
const featureFlagEnabled = Boolean(evaluation.enabled);
|
||||
|
||||
return {
|
||||
envEnabled,
|
||||
featureFlagEnabled,
|
||||
effectiveEnabled: envEnabled && featureFlagEnabled,
|
||||
effectiveReason: envEnabled ? evaluation.reason : 'env_disabled',
|
||||
provider: config.erpSyncProvider,
|
||||
masterDataEndpointConfigured: Boolean(String(config.erpMasterDataEndpoint ?? '').trim()),
|
||||
journalEndpointConfigured: Boolean(
|
||||
String(config.erpJournalExportEndpoint ?? config.erpSyncEndpoint ?? '').trim(),
|
||||
),
|
||||
masterDataSyncCron: String(config.erpMasterDataSyncCron ?? '').trim() || null,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireErpManagementGate(prisma, config) {
|
||||
const gate = await resolveErpGate(prisma, config);
|
||||
if (gate.effectiveEnabled) {
|
||||
return gate;
|
||||
}
|
||||
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: gate.envEnabled ? 'feature_flag_blocked' : 'erp_sync_disabled',
|
||||
featureFlag: gate.featureFlag,
|
||||
reason: gate.effectiveReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchReimbursementForErp(prisma, reimbursementId) {
|
||||
const reimbursement = await prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
paid_at: true,
|
||||
owner: {
|
||||
select: {
|
||||
legal_entity: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
submitter: {
|
||||
select: {
|
||||
legal_entity: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursement) {
|
||||
throw new AppError({
|
||||
code: 'SYS_404',
|
||||
message: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
function ensurePaidReimbursement(reimbursement) {
|
||||
if (reimbursement.status !== 'paid') {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 409,
|
||||
details: { reimbursement: 'erp_sync_requires_paid' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueReimbursementExportAndMaybeRun({
|
||||
prisma,
|
||||
config,
|
||||
logger,
|
||||
reimbursement,
|
||||
actorId,
|
||||
runNow,
|
||||
}) {
|
||||
const enqueued = await enqueueReimbursementJournalExport(prisma, config, {
|
||||
reimbursement,
|
||||
actorId,
|
||||
});
|
||||
|
||||
if (runNow) {
|
||||
await runIntegrationOutboxOnce(prisma, config, logger);
|
||||
}
|
||||
|
||||
return enqueued;
|
||||
}
|
||||
|
||||
function normalizeResourcesInput(value) {
|
||||
const parsed = MASTER_DATA_SYNC_BODY_SCHEMA.safeParse(value ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeRequestedResources(parsed.data.resources ?? []);
|
||||
}
|
||||
|
||||
async function integrationsRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/integrations/erp/sync/:reimbursementId",
|
||||
fastify.get(
|
||||
'/integrations/erp/master-data/status',
|
||||
{
|
||||
preHandler: fastify.requirePermission("integration.manage"),
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ["integrations"],
|
||||
summary: "手动触发 ERP 同步",
|
||||
tags: ['integrations'],
|
||||
summary: '查询 ERP 主数据同步状态',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步状态'),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const [items, gate] = await Promise.all([
|
||||
listMasterDataSyncStates(fastify.prisma),
|
||||
resolveErpGate(fastify.prisma, fastify.config),
|
||||
]);
|
||||
return sendData(reply, request, { gate, items });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/erp/master-data/sync',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '手动触发 ERP 主数据同步',
|
||||
security: [{ bearerAuth: [] }],
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_MASTER_DATA_STATUS_SCHEMA, 'ERP 主数据同步结果'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: 'ERP 总闸关闭或同步失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const resources = normalizeResourcesInput(request.body);
|
||||
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||
const result = await syncErpMasterData(fastify.prisma, fastify.config, {
|
||||
resources,
|
||||
actorId: request.employee.id,
|
||||
logger: fastify.log,
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: 'ERP_MASTER_DATA_SYNC_TRIGGERED',
|
||||
targetType: 'integration_master_data',
|
||||
extra: {
|
||||
resources,
|
||||
gateReason: gate.effectiveReason,
|
||||
provider: gate.provider,
|
||||
ok: result.ok,
|
||||
errorCode: result.error?.code ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: result.error?.code ?? 'erp_master_data_sync_failed',
|
||||
reason: result.error?.message ?? 'erp_master_data_sync_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
gate,
|
||||
items: result.items.filter((item) => resources.includes(item.resourceType)),
|
||||
triggerMode: 'manual',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/integrations/erp/journals/reimbursements/:reimbursementId/preview',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '预览报销付款凭证切片',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["reimbursementId"],
|
||||
type: 'object',
|
||||
required: ['reimbursementId'],
|
||||
properties: {
|
||||
reimbursementId: { type: "string", format: "uuid" },
|
||||
reimbursementId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: { reimbursementId: 'invalid_uuid' },
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
|
||||
ensurePaidReimbursement(reimbursement);
|
||||
const preview = await buildReimbursementJournalPreview(fastify.prisma, reimbursement);
|
||||
return sendData(reply, request, {
|
||||
journal: preview.journal,
|
||||
exportState: preview.exportState,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/erp/journals/reimbursements/:reimbursementId/export',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '导出报销付款凭证到 ERP',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['reimbursementId'],
|
||||
properties: {
|
||||
reimbursementId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
runNow: {
|
||||
anyOf: [{ type: "string", enum: ["1", "true"] }, { type: "boolean" }],
|
||||
anyOf: [
|
||||
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
|
||||
{ type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, "ERP 同步任务已入队"),
|
||||
200: buildDataEnvelopeSchema(ERP_JOURNAL_EXPORT_RESPONSE_SCHEMA, 'ERP 导出任务已入队'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: "参数校验失败",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: "报销单不存在",
|
||||
codeExample: "SYS_404",
|
||||
messageExample: "Not Found",
|
||||
description: '报销单不存在',
|
||||
codeExample: 'SYS_404',
|
||||
messageExample: 'Not Found',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: "状态不满足或开关阻断",
|
||||
codeExample: "VALID_001",
|
||||
messageExample: "参数校验失败",
|
||||
description: '状态不满足或开关阻断',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: { reimbursementId: 'invalid_uuid' },
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
|
||||
ensurePaidReimbursement(reimbursement);
|
||||
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||
const enqueued = await enqueueReimbursementExportAndMaybeRun({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
logger: fastify.log,
|
||||
reimbursement,
|
||||
actorId: request.employee.id,
|
||||
runNow: parseRunNow(request.query),
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: 'ERP_SYNC_ENQUEUED',
|
||||
targetType: 'reimbursement_form',
|
||||
targetId: reimbursement.id,
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
eventType: enqueued.eventType,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
featureFlagReason: gate.effectiveReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
outboxId: enqueued.id,
|
||||
status: enqueued.status,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
eventType: enqueued.eventType,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/integrations/erp/sync/:reimbursementId',
|
||||
{
|
||||
preHandler: fastify.requirePermission('integration.manage'),
|
||||
schema: {
|
||||
tags: ['integrations'],
|
||||
summary: '手动触发 ERP 同步',
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['reimbursementId'],
|
||||
properties: {
|
||||
reimbursementId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runNow: {
|
||||
anyOf: [
|
||||
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
|
||||
{ type: 'boolean' },
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
response: {
|
||||
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, 'ERP 同步任务已入队'),
|
||||
400: buildErrorEnvelopeSchema({
|
||||
description: '参数校验失败',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
}),
|
||||
404: buildErrorEnvelopeSchema({
|
||||
description: '报销单不存在',
|
||||
codeExample: 'SYS_404',
|
||||
messageExample: 'Not Found',
|
||||
}),
|
||||
409: buildErrorEnvelopeSchema({
|
||||
description: '状态不满足或开关阻断',
|
||||
codeExample: 'VALID_001',
|
||||
messageExample: '参数校验失败',
|
||||
detailsExample: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: "erp_sync",
|
||||
reason: "department_not_allowed",
|
||||
integration: 'feature_flag_blocked',
|
||||
featureFlag: 'erp_sync',
|
||||
reason: 'department_not_allowed',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
|
||||
const parsedId = UUID_SCHEMA.safeParse(request.params?.reimbursementId);
|
||||
if (!parsedId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
code: 'VALID_001',
|
||||
message: '参数校验失败',
|
||||
statusCode: 400,
|
||||
details: { reimbursementId: "invalid_uuid" },
|
||||
details: { reimbursementId: 'invalid_uuid' },
|
||||
});
|
||||
}
|
||||
|
||||
const reimbursementId = parsedId.data;
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
form_number: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
total_amount: true,
|
||||
payment_voucher_no: true,
|
||||
payment_date: true,
|
||||
paid_at: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reimbursement) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (reimbursement.status !== "paid") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "erp_sync_requires_paid" },
|
||||
});
|
||||
}
|
||||
|
||||
const erpGate = await evaluateFeatureFlag({
|
||||
const reimbursement = await fetchReimbursementForErp(fastify.prisma, parsedId.data);
|
||||
ensurePaidReimbursement(reimbursement);
|
||||
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
|
||||
const enqueued = await enqueueReimbursementExportAndMaybeRun({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
||||
strictDepartment: true,
|
||||
});
|
||||
|
||||
if (!erpGate.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: {
|
||||
integration: "feature_flag_blocked",
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
reason: erpGate.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildErpPayload({ reimbursement });
|
||||
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload,
|
||||
dedupeKey: reimbursement.id,
|
||||
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
|
||||
logger: fastify.log,
|
||||
reimbursement,
|
||||
actorId: request.employee.id,
|
||||
runNow: parseRunNow(request.query),
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ERP_SYNC_ENQUEUED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: reimbursementId,
|
||||
action: 'ERP_SYNC_ENQUEUED',
|
||||
targetType: 'reimbursement_form',
|
||||
targetId: reimbursement.id,
|
||||
extra: {
|
||||
outboxId: enqueued.id,
|
||||
duplicated: enqueued.duplicated,
|
||||
requeued: enqueued.requeued,
|
||||
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||
departmentId: reimbursement.department_id ?? null,
|
||||
featureFlagReason: erpGate.reason,
|
||||
featureFlagReason: gate.effectiveReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const runNow =
|
||||
request.query?.runNow === "1" ||
|
||||
request.query?.runNow === 1 ||
|
||||
request.query?.runNow === true ||
|
||||
request.query?.runNow === "true";
|
||||
if (runNow) {
|
||||
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
outboxId: enqueued.id,
|
||||
status: enqueued.status,
|
||||
@@ -182,4 +554,9 @@ async function integrationsRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
integrationsRoutes.__testables = {
|
||||
normalizeResourcesInput,
|
||||
parseRunNow,
|
||||
};
|
||||
|
||||
module.exports = integrationsRoutes;
|
||||
|
||||
在新工单中引用
屏蔽一个用户