feat(P20T3-1,P20T3-2,P20T3-3): 对账批次引擎与ERP摘要出站后端

这个提交包含在:
2026-03-11 16:55:04 +08:00
父节点 41a169ea41
当前提交 fc0295249e
共修改 15 个文件,包含 3733 行新增和 28 行删除
+2
查看文件
@@ -47,6 +47,7 @@ const travelOrdersRoutes = require("./routes/travelOrders");
const travelRequestsRoutes = require("./routes/travelRequests");
const travelAuthorizationsRoutes = require("./routes/travelAuthorizations");
const travelTransactionsRoutes = require("./routes/travelTransactions");
const travelReconciliationRoutes = require("./routes/travelReconciliation");
const aiRoutes = require("./routes/ai");
const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability");
@@ -312,6 +313,7 @@ async function buildApp() {
await app.register(travelRequestsRoutes, { prefix: "/api" });
await app.register(travelAuthorizationsRoutes, { prefix: "/api" });
await app.register(travelTransactionsRoutes, { prefix: "/api" });
await app.register(travelReconciliationRoutes, { prefix: "/api" });
await app.register(aiRoutes, { prefix: "/api" });
await app.register(exportsRoutes, { prefix: "/api" });
+221
查看文件
@@ -36,6 +36,13 @@ const {
loadAssetErpContext,
resolveAssetLedgerSummaryDrift,
} = require('../services/integrations/erpAsset');
const {
TRAVEL_ERP_EVENT_TYPE,
buildTravelReconciliationErpTrack,
buildTravelReconciliationSummaryDedupeKey,
enqueueTravelReconciliationSummaryExport,
loadTravelReconciliationErpContext,
} = require('../services/integrations/erpTravel');
const UUID_SCHEMA = z.string().uuid();
const MASTER_DATA_RESOURCE_ENUM = z.enum(['legal_entity', 'cost_center', 'supplier']);
@@ -320,6 +327,78 @@ async function fetchReadableAssetContext(prisma, assetId, request) {
return context;
}
function canReadTravelReconciliationBatch(request) {
const permissionCodes = request?.employee?.permissionCodes ?? [];
return (
permissionCodes.includes('*') ||
permissionCodes.includes('travel.payment.manage') ||
permissionCodes.includes('integration.manage')
);
}
async function fetchReadableTravelReconciliationContext(prisma, batchId, request) {
const context = await loadTravelReconciliationErpContext(prisma, batchId);
if (!context) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (!canReadTravelReconciliationBatch(request)) {
throw new AppError({
code: 'PERM_001',
message: '无权限',
statusCode: 403,
});
}
return context;
}
async function findTravelReconciliationOutboxByDedupeKey(prisma, batchId) {
return prisma.integrationOutbox.findUnique({
where: {
event_type_dedupe_key: {
event_type: TRAVEL_ERP_EVENT_TYPE,
dedupe_key: buildTravelReconciliationSummaryDedupeKey(batchId),
},
},
select: {
id: true,
status: true,
payload: true,
},
});
}
async function enqueueTravelReconciliationExportAndMaybeRun({
prisma,
config,
logger,
context,
actorId,
runNow,
findExistingOutbox = findTravelReconciliationOutboxByDedupeKey,
enqueueExport = enqueueTravelReconciliationSummaryExport,
runOutboxOnce = runIntegrationOutboxOnce,
}) {
const existingOutbox = await findExistingOutbox(prisma, context.id);
const enqueued = await enqueueExport(prisma, config, {
batch: context,
payload: existingOutbox?.payload,
actorId,
requeueOnFailure: true,
});
if (runNow && (!enqueued.duplicated || enqueued.requeued)) {
await runOutboxOnce(prisma, config, logger);
}
return enqueued;
}
async function findAssetOutboxByDedupeKey(prisma, assetId) {
return prisma.integrationOutbox.findUnique({
where: {
@@ -984,6 +1063,146 @@ async function integrationsRoutes(fastify) {
},
);
fastify.get(
'/integrations/erp/travel/reconciliation-batches/:batchId/track',
{
preHandler: fastify.authenticate,
schema: {
tags: ['integrations'],
summary: '查询差旅对账批次 ERP 同步轨道',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['batchId'],
properties: {
batchId: { type: 'string', format: 'uuid' },
},
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { batchId: 'invalid_uuid' },
});
}
const [context, gate] = await Promise.all([
fetchReadableTravelReconciliationContext(fastify.prisma, parsedId.data, request),
resolveErpGate(fastify.prisma, fastify.config),
]);
const canManage = fastify.hasPermission(request.employee, 'integration.manage');
const track = await buildTravelReconciliationErpTrack(fastify.prisma, fastify.config, parsedId.data, {
context,
canManage,
gate,
});
return sendData(reply, request, track);
},
);
fastify.post(
'/integrations/erp/travel/reconciliation-batches/:batchId/requeue',
{
preHandler: fastify.requirePermission('integration.manage'),
schema: {
tags: ['integrations'],
summary: '重试差旅对账批次 ERP 摘要桥同步',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
required: ['batchId'],
properties: {
batchId: { type: 'string', format: 'uuid' },
},
},
querystring: {
type: 'object',
properties: {
runNow: {
anyOf: [
{ type: 'string', enum: ['0', '1', 'false', 'true'] },
{ type: 'boolean' },
],
},
},
additionalProperties: true,
},
},
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId);
if (!parsedId.success) {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 400,
details: { batchId: 'invalid_uuid' },
});
}
const gate = await requireErpManagementGate(fastify.prisma, fastify.config);
const context = await loadTravelReconciliationErpContext(fastify.prisma, parsedId.data);
if (!context) {
throw new AppError({
code: 'SYS_404',
message: 'Not Found',
statusCode: 404,
});
}
if (context.status !== 'reconciled') {
throw new AppError({
code: 'VALID_001',
message: '参数校验失败',
statusCode: 409,
details: { batch: 'travel_reconciliation_batch_not_reconciled' },
});
}
const enqueued = await enqueueTravelReconciliationExportAndMaybeRun({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
context,
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: 'travel_reconciliation_batch',
targetId: context.id,
extra: {
erpTarget: 'travel_reconciliation_summary',
eventType: enqueued.eventType,
batchId: context.id,
batchNo: context.batch_no,
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
requeued: enqueued.requeued,
gateReason: gate.effectiveReason,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
},
}),
});
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',
{
@@ -1087,7 +1306,9 @@ async function integrationsRoutes(fastify) {
integrationsRoutes.__testables = {
canReadAsset,
canReadPurchaseRequest,
canReadTravelReconciliationBatch,
enqueueAssetExportAndMaybeRun,
enqueueTravelReconciliationExportAndMaybeRun,
ensureProcurementTargetEligible,
normalizeProcurementTargets,
normalizeResourcesInput,
+738
查看文件
@@ -0,0 +1,738 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { issueDocumentNumber } = require("../services/documentSequence");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
TRAVEL_RECONCILIATION_BATCH_STATUSES,
TRAVEL_RECONCILIATION_RESOLUTION_STATUSES,
buildReconciliationItemsSnapshot,
buildValidationError,
ensureBatchFinalizable,
ensureConfirmedReconciliationItem,
ensureDraftBatch,
ensurePendingReconciliationItem,
ensureReopenableReconciliationItem,
listTravelReconciliationBatches,
loadReconciliationScope,
loadTravelReconciliationBatchDetail,
mapBatchItem,
normalizeSummaryJson,
} = require("../services/travelReconciliation");
const {
TRAVEL_ERP_EVENT_TYPE,
buildTravelReconciliationSummaryDedupeKey,
enqueueTravelReconciliationSummaryExport,
} = require("../services/integrations/erpTravel");
const UUID_SCHEMA = z.string().uuid();
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "false", "true"]).optional();
const BATCH_STATUS_SCHEMA = z.enum(["draft", "reconciled"]);
const LIST_QUERY_SCHEMA = z.object({
status: BATCH_STATUS_SCHEMA.optional(),
legalEntityId: UUID_SCHEMA.optional(),
costCenterId: UUID_SCHEMA.optional(),
periodStart: DATE_ONLY_SCHEMA.optional(),
periodEnd: DATE_ONLY_SCHEMA.optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
runNow: BOOLEAN_QUERY_SCHEMA,
});
const CREATE_BODY_SCHEMA = z
.object({
legalEntityId: UUID_SCHEMA,
costCenterId: UUID_SCHEMA.optional(),
periodStart: DATE_ONLY_SCHEMA,
periodEnd: DATE_ONLY_SCHEMA,
})
.strict();
const NOTE_BODY_SCHEMA = z
.object({
note: z.string().trim().max(500).nullable().optional(),
})
.strict();
function normalizeNullableString(value) {
if (value == null) {
return null;
}
const normalized = String(value).trim();
return normalized || null;
}
function ensureReconciliationReadPermission(fastify, request) {
const allowed =
fastify.hasPermission(request.employee, "travel.payment.manage") ||
fastify.hasPermission(request.employee, "integration.manage");
if (!allowed) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
function parseListQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten(), 400);
}
return {
status: parsed.data.status,
legalEntityId: parsed.data.legalEntityId ?? null,
costCenterId: parsed.data.costCenterId ?? null,
periodStart: parsed.data.periodStart ? parseDateOnlyString(parsed.data.periodStart) : null,
periodEnd: parsed.data.periodEnd ? parseDateOnlyString(parsed.data.periodEnd) : null,
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function parseRunNow(query) {
return query?.runNow === "1" || query?.runNow === 1 || query?.runNow === true || query?.runNow === "true";
}
function ensureValidPeriod(periodStart, periodEnd) {
if (periodStart.getTime() > periodEnd.getTime()) {
throw buildValidationError({ period: "period_start_after_period_end" });
}
}
async function validateBatchScopeContext(prisma, { legalEntityId, costCenterId }) {
const [legalEntity, costCenter] = await Promise.all([
prisma.legalEntity.findUnique({
where: { id: legalEntityId },
select: { id: true, code: true, name: true },
}),
costCenterId
? prisma.costCenter.findUnique({
where: { id: costCenterId },
select: { id: true, code: true, name: true, legal_entity_id: true },
})
: Promise.resolve(null),
]);
if (!legalEntity) {
throw buildValidationError({ legalEntityId: "not_found" });
}
if (costCenterId && !costCenter) {
throw buildValidationError({ costCenterId: "not_found" });
}
if (costCenter && costCenter.legal_entity_id !== legalEntityId) {
throw buildValidationError({ costCenterId: "legal_entity_mismatch" });
}
return { legalEntity, costCenter };
}
async function loadBatchForUpdate(tx, batchId) {
const rows = await tx.$queryRaw`
SELECT id
FROM "travel_reconciliation_batches"
WHERE id = ${batchId}::uuid
FOR UPDATE
`;
if (!Array.isArray(rows) || rows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return tx.travelReconciliationBatch.findUnique({
where: { id: batchId },
select: {
id: true,
batch_no: true,
legal_entity_id: true,
cost_center_id: true,
status: true,
summary_json: true,
confirmed_at: true,
confirmed_by: true,
items: {
select: {
id: true,
resolution_status: true,
},
},
},
});
}
async function loadItemForUpdate(tx, itemId) {
const rows = await tx.$queryRaw`
SELECT id
FROM "travel_reconciliation_items"
WHERE id = ${itemId}::uuid
FOR UPDATE
`;
if (!Array.isArray(rows) || rows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return tx.travelReconciliationItem.findUnique({
where: { id: itemId },
select: {
id: true,
travel_reconciliation_batch_id: true,
match_status: true,
resolution_status: true,
confirmed_at: true,
confirmed_by: true,
confirm_note: true,
batch: {
select: {
id: true,
status: true,
summary_json: true,
},
},
travel_request: {
select: {
id: true,
request_no: true,
},
},
travel_order: {
select: {
id: true,
external_order_no: true,
status: true,
},
},
card_transaction: {
select: {
id: true,
external_txn_id: true,
status: true,
txn_type: true,
},
},
order_amount: true,
transaction_amount: true,
currency: true,
anomaly_code: true,
snapshot_json: true,
created_at: true,
updated_at: true,
travel_request_id: true,
travel_order_id: true,
card_transaction_id: true,
},
});
}
async function syncBatchPendingSummary(tx, batchId) {
const batch = await tx.travelReconciliationBatch.findUnique({
where: { id: batchId },
select: {
id: true,
summary_json: true,
items: {
select: {
resolution_status: true,
},
},
},
});
if (!batch) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const summary = normalizeSummaryJson(batch.summary_json);
summary.pendingItems = batch.items.filter(
(item) => item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
).length;
await tx.travelReconciliationBatch.update({
where: { id: batchId },
data: {
summary_json: summary,
},
});
return summary;
}
async function travelReconciliationRoutes(fastify) {
fastify.get(
"/travel/reconciliation/batches",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureReconciliationReadPermission(fastify, request);
const query = parseListQuery(request.query);
const result = await listTravelReconciliationBatches(fastify.prisma, query);
return sendData(reply, request, result);
},
);
fastify.post(
"/travel/reconciliation/batches",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const parsed = CREATE_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten(), 400);
}
const costCenterId = parsed.data.costCenterId ?? null;
const periodStart = parseDateOnlyString(parsed.data.periodStart);
const periodEnd = parseDateOnlyString(parsed.data.periodEnd);
ensureValidPeriod(periodStart, periodEnd);
const scopeContext = await validateBatchScopeContext(fastify.prisma, {
legalEntityId: parsed.data.legalEntityId,
costCenterId,
});
const scope = await loadReconciliationScope(fastify.prisma, {
legalEntityId: parsed.data.legalEntityId,
costCenterId,
periodStart,
periodEnd,
});
if (!scope.orders.length && !scope.transactions.length) {
throw buildValidationError({ batch: "travel_reconciliation_empty_scope" });
}
const now = new Date();
const snapshot = buildReconciliationItemsSnapshot({
orders: scope.orders,
transactions: scope.transactions,
invoices: scope.invoices,
actorId: request.employee.id,
now,
});
const issued = await issueDocumentNumber(fastify.prisma, {
documentType: "travel_reconciliation_batch",
actorId: request.employee.id,
timeZone: fastify.config.appTimezone,
});
let batchId = null;
await fastify.prisma.$transaction(async (tx) => {
const batch = await tx.travelReconciliationBatch.create({
data: {
batch_no: issued.documentNumber,
legal_entity_id: parsed.data.legalEntityId,
cost_center_id: costCenterId,
period_start: periodStart,
period_end: periodEnd,
status: TRAVEL_RECONCILIATION_BATCH_STATUSES.draft,
summary_json: snapshot.summary,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true },
});
batchId = batch.id;
if (snapshot.items.length) {
await tx.travelReconciliationItem.createMany({
data: snapshot.items.map((item) => ({
...item,
travel_reconciliation_batch_id: batch.id,
})),
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_RECONCILIATION_BATCH_CREATED",
targetType: "travel_reconciliation_batch",
targetId: batch.id,
extra: {
batchNo: issued.documentNumber,
legalEntityId: parsed.data.legalEntityId,
costCenterId,
legalEntityCode: scopeContext.legalEntity.code,
costCenterCode: scopeContext.costCenter?.code ?? null,
periodStart: parsed.data.periodStart,
periodEnd: parsed.data.periodEnd,
summary: snapshot.summary,
},
}),
});
});
const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, batchId, {
canManagePayments: true,
canRetryErp: fastify.hasPermission(request.employee, "integration.manage"),
});
return sendData(reply, request, detail);
},
);
fastify.get(
"/travel/reconciliation/batches/:batchId",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureReconciliationReadPermission(fastify, request);
const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId);
if (!parsedId.success) {
throw buildValidationError({ batchId: "invalid_uuid" }, 400);
}
const detail = await loadTravelReconciliationBatchDetail(fastify.prisma, parsedId.data, {
canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"),
canRetryErp: fastify.hasPermission(request.employee, "integration.manage"),
});
return sendData(reply, request, detail);
},
);
fastify.post(
"/travel/reconciliation/items/:itemId/confirm",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId);
if (!parsedId.success) {
throw buildValidationError({ itemId: "invalid_uuid" }, 400);
}
const parsedBody = NOTE_BODY_SCHEMA.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw buildValidationError(parsedBody.error.flatten(), 400);
}
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const item = await loadItemForUpdate(tx, parsedId.data);
ensureDraftBatch(item.batch);
ensurePendingReconciliationItem(item);
const updated = await tx.travelReconciliationItem.update({
where: { id: parsedId.data },
data: {
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed,
confirmed_at: new Date(),
confirmed_by: request.employee.id,
confirm_note: normalizeNullableString(parsedBody.data.note),
},
select: {
id: true,
item_kind: true,
travel_request_id: true,
travel_order_id: true,
card_transaction_id: true,
match_status: true,
resolution_status: true,
order_amount: true,
transaction_amount: true,
currency: true,
anomaly_code: true,
snapshot_json: true,
confirmed_at: true,
confirmed_by: true,
confirm_note: true,
created_at: true,
updated_at: true,
travel_request: { select: { id: true, request_no: true } },
travel_order: { select: { id: true, external_order_no: true, status: true } },
card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true } },
},
});
const summary = await syncBatchPendingSummary(tx, item.batch.id);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_RECONCILIATION_ITEM_CONFIRMED",
targetType: "travel_reconciliation_item",
targetId: item.id,
extra: {
batchId: item.batch.id,
matchStatus: item.match_status,
note: normalizeNullableString(parsedBody.data.note),
summary,
},
}),
});
response = {
batchId: item.batch.id,
summary,
item: mapBatchItem(updated, { canManagePayments: true, batchStatus: item.batch.status }),
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/travel/reconciliation/items/:itemId/reopen",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.itemId);
if (!parsedId.success) {
throw buildValidationError({ itemId: "invalid_uuid" }, 400);
}
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const item = await loadItemForUpdate(tx, parsedId.data);
ensureDraftBatch(item.batch);
ensureConfirmedReconciliationItem(item);
ensureReopenableReconciliationItem(item);
const updated = await tx.travelReconciliationItem.update({
where: { id: parsedId.data },
data: {
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
confirmed_at: null,
confirmed_by: null,
confirm_note: null,
},
select: {
id: true,
item_kind: true,
travel_request_id: true,
travel_order_id: true,
card_transaction_id: true,
match_status: true,
resolution_status: true,
order_amount: true,
transaction_amount: true,
currency: true,
anomaly_code: true,
snapshot_json: true,
confirmed_at: true,
confirmed_by: true,
confirm_note: true,
created_at: true,
updated_at: true,
travel_request: { select: { id: true, request_no: true } },
travel_order: { select: { id: true, external_order_no: true, status: true } },
card_transaction: { select: { id: true, external_txn_id: true, status: true, txn_type: true } },
},
});
const summary = await syncBatchPendingSummary(tx, item.batch.id);
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_RECONCILIATION_ITEM_REOPENED",
targetType: "travel_reconciliation_item",
targetId: item.id,
extra: {
batchId: item.batch.id,
matchStatus: item.match_status,
summary,
},
}),
});
response = {
batchId: item.batch.id,
summary,
item: mapBatchItem(updated, { canManagePayments: true, batchStatus: item.batch.status }),
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/travel/reconciliation/batches/:batchId/finalize",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.batchId);
if (!parsedId.success) {
throw buildValidationError({ batchId: "invalid_uuid" }, 400);
}
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchForUpdate(tx, parsedId.data);
const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id);
if (batch.status === TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled) {
const existingOutbox = await tx.integrationOutbox.findUnique({
where: {
event_type_dedupe_key: {
event_type: TRAVEL_ERP_EVENT_TYPE,
dedupe_key: dedupeKey,
},
},
select: { id: true },
});
response = {
batchId: batch.id,
batchNo: batch.batch_no,
status: batch.status,
erpExport: {
eventType: TRAVEL_ERP_EVENT_TYPE,
outboxId: existingOutbox?.id ?? null,
duplicated: Boolean(existingOutbox),
requeued: false,
enqueued: Boolean(existingOutbox),
},
};
return;
}
ensureDraftBatch(batch);
ensureBatchFinalizable(batch.items);
const finalizedAt = new Date();
await tx.travelReconciliationBatch.update({
where: { id: batch.id },
data: {
status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled,
confirmed_at: finalizedAt,
confirmed_by: request.employee.id,
updated_by: request.employee.id,
summary_json: {
...normalizeSummaryJson(batch.summary_json),
pendingItems: 0,
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_RECONCILIATION_FINALIZED",
targetType: "travel_reconciliation_batch",
targetId: batch.id,
extra: {
batchNo: batch.batch_no,
itemCount: batch.items.length,
},
}),
});
const gate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: null,
strictDepartment: false,
});
let erpExport = {
eventType: TRAVEL_ERP_EVENT_TYPE,
outboxId: null,
duplicated: false,
requeued: false,
enqueued: false,
};
if (fastify.config.erpSyncEnabled && gate.enabled) {
const exportContext = await tx.travelReconciliationBatch.findUnique({
where: { id: batch.id },
select: {
id: true,
batch_no: true,
legal_entity_id: true,
cost_center_id: true,
period_start: true,
period_end: true,
status: true,
summary_json: true,
confirmed_at: true,
created_at: true,
updated_at: true,
legal_entity: { select: { id: true, code: true, name: true } },
cost_center: { select: { id: true, code: true, name: true } },
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
travel_request_id: true,
travel_order_id: true,
card_transaction_id: true,
match_status: true,
resolution_status: true,
order_amount: true,
transaction_amount: true,
currency: true,
snapshot_json: true,
},
},
},
});
const outbox = await enqueueTravelReconciliationSummaryExport(tx, fastify.config, {
batch: exportContext,
actorId: request.employee.id,
requeueOnFailure: true,
});
erpExport = {
eventType: TRAVEL_ERP_EVENT_TYPE,
outboxId: outbox.id,
duplicated: outbox.duplicated,
requeued: outbox.requeued,
enqueued: true,
};
} else {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
targetType: "travel_reconciliation_batch",
targetId: batch.id,
extra: {
featureFlag: FEATURE_FLAG_KEYS.erpSync,
reason: fastify.config.erpSyncEnabled ? gate.reason : "env_disabled",
},
}),
});
}
response = {
batchId: batch.id,
batchNo: batch.batch_no,
status: TRAVEL_RECONCILIATION_BATCH_STATUSES.reconciled,
erpExport,
};
});
return sendData(reply, request, response);
},
);
}
travelReconciliationRoutes.__testables = {
ensureReconciliationReadPermission,
parseListQuery,
parseRunNow,
};
module.exports = travelReconciliationRoutes;
+11
查看文件
@@ -50,6 +50,17 @@ const DOCUMENT_TYPE_CONFIG = {
resetPeriod: "yearly",
},
},
travel_reconciliation_batch: {
defaultCategoryCode: "TR_RECON",
preferLegacySequence: false,
fallback: {
prefix: "TRB",
dateFormat: "YYYYMMDD",
separator: "-",
numberLength: 4,
resetPeriod: "yearly",
},
},
asset_card: {
defaultCategoryCode: "ASSET_CARD_DEFAULT",
preferLegacySequence: false,
+25 -4
查看文件
@@ -3,6 +3,7 @@ const {
exportAssetToErp,
exportJournalToErp,
exportProcurementToErp,
exportTravelToErp,
} = require('./erp');
const { sendInstantMessageByChannel } = require('../notifications/channels');
@@ -159,6 +160,23 @@ function resolveBindingSpecs(eventType, payload, response) {
].filter(Boolean);
}
if (eventType === 'erp.travel.export') {
return [
createBindingSpec(
'travel_payable_summary',
payload?.entityId,
extractResponseValue(response, 'externalSummaryId'),
extractResponseValue(response, 'externalSummaryNo') ?? payload?.batchNo ?? null,
),
createBindingSpec(
'travel_settlement_batch',
payload?.entityId,
extractResponseValue(response, 'externalBatchId'),
extractResponseValue(response, 'externalBatchNo') ?? payload?.batchNo ?? null,
),
].filter(Boolean);
}
return [];
}
@@ -204,9 +222,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) {
? await exportProcurementToErp(config, payload)
: eventType === 'erp.asset.export'
? await exportAssetToErp(config, payload)
: eventType === 'erp.journal.export'
? await exportJournalToErp(config, payload)
: await pushPaidReimbursementToErp(config, payload);
: eventType === 'erp.travel.export'
? await exportTravelToErp(config, payload)
: eventType === 'erp.journal.export'
? await exportJournalToErp(config, payload)
: await pushPaidReimbursementToErp(config, payload);
if (!result.ok) {
throw toDispatchError(result);
@@ -226,7 +246,8 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
eventType === 'erp.reimbursement.paid' ||
eventType === 'erp.journal.export' ||
eventType === 'erp.procurement.export' ||
eventType === 'erp.asset.export'
eventType === 'erp.asset.export' ||
eventType === 'erp.travel.export'
) {
return dispatchErpEvent({ prisma, config, eventType, payload });
}
@@ -0,0 +1,333 @@
const { enqueueIntegrationEvent } = require("./outbox");
const { normalizeSummaryJson, toMoneyFixed } = require("../travelReconciliation");
const TRAVEL_ERP_EVENT_TYPE = "erp.travel.export";
const TRAVEL_ERP_EXPORT_TYPE = "travel_reconciliation_summary";
const TRAVEL_ERP_BINDING_TYPES = Object.freeze({
payableSummary: "travel_payable_summary",
settlementBatch: "travel_settlement_batch",
});
const TRAVEL_ERP_TRACK_STATUSES = Object.freeze({
notTriggered: "not_triggered",
skipped: "skipped",
});
const TRAVEL_RECONCILIATION_ERP_SELECT = {
id: true,
batch_no: true,
legal_entity_id: true,
cost_center_id: true,
period_start: true,
period_end: true,
status: true,
summary_json: true,
confirmed_at: true,
created_at: true,
updated_at: true,
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
},
},
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
travel_request_id: true,
travel_order_id: true,
card_transaction_id: true,
match_status: true,
resolution_status: true,
order_amount: true,
transaction_amount: true,
currency: true,
snapshot_json: true,
},
},
};
function toDateOnly(value) {
if (!value) {
return null;
}
try {
return new Date(value).toISOString().slice(0, 10);
} catch {
return null;
}
}
function toIsoString(value) {
if (!value) {
return null;
}
try {
return new Date(value).toISOString();
} catch {
return null;
}
}
function mapIntegrationBinding(record) {
if (!record) {
return null;
}
return {
id: record.id,
provider: record.provider,
entityType: record.entity_type,
localId: record.local_id,
externalId: record.external_id ?? null,
externalCode: record.external_code ?? null,
lastSyncedAt: record.last_synced_at?.toISOString?.() ?? null,
};
}
function redactBinding(binding, canManage) {
if (!binding) {
return null;
}
if (canManage) {
return binding;
}
return {
...binding,
externalId: null,
externalCode: null,
};
}
function mapIntegrationOutboxState(record) {
if (!record) {
return null;
}
return {
id: record.id,
eventType: record.event_type,
status: record.status,
attempts: Number(record.attempts ?? 0),
lastError: record.last_error ?? null,
processedAt: record.processed_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function buildTravelReconciliationSummaryDedupeKey(batchId) {
return `travel_reconciliation_batch:${batchId}:summary`;
}
function mapPayloadItem(item) {
const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {};
return {
travelRequestId: item.travel_request_id ?? null,
requestNo: snapshot.requestNo ?? null,
travelOrderId: item.travel_order_id ?? null,
externalOrderNo: snapshot.externalOrderNo ?? null,
cardTransactionId: item.card_transaction_id ?? null,
externalTxnId: snapshot.externalTxnId ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
orderAmount: item.order_amount?.toString?.() ?? null,
transactionAmount: item.transaction_amount?.toString?.() ?? null,
currency: item.currency ?? "CNY",
linkedReimbursementId: snapshot.linkedReimbursementId ?? null,
boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0),
};
}
function buildTravelReconciliationSummaryPayload(input) {
const batch = input?.batch ?? {};
const summary = normalizeSummaryJson(batch.summary_json);
const items = Array.isArray(batch.items) ? batch.items.map(mapPayloadItem) : [];
return {
exportType: TRAVEL_ERP_EXPORT_TYPE,
entityType: "travel_reconciliation_batch",
entityId: batch.id ?? null,
batchNo: batch.batch_no ?? null,
occurredAt:
toIsoString(batch.confirmed_at) ??
toIsoString(batch.updated_at) ??
toIsoString(batch.created_at),
periodStart: toDateOnly(batch.period_start),
periodEnd: toDateOnly(batch.period_end),
dimensions: {
legalEntityCode: batch.legal_entity?.code ?? null,
costCenterCode: batch.cost_center?.code ?? null,
},
summary: {
totalOrders: Number(summary.totalOrders ?? 0),
totalTransactions: Number(summary.totalTransactions ?? 0),
anomalyItems: Number(summary.anomalyItems ?? 0),
matchRate: toMoneyFixed(summary.matchRate),
totalOrderAmount: toMoneyFixed(summary.totalOrderAmount),
totalSettledAmount: toMoneyFixed(summary.totalSettledAmount),
totalReversedAmount: toMoneyFixed(summary.totalReversedAmount),
},
items,
};
}
async function enqueueTravelReconciliationSummaryExport(prisma, config, input) {
const payload =
input?.payload ?? buildTravelReconciliationSummaryPayload({
batch: input?.batch,
});
const enqueued = await enqueueIntegrationEvent(prisma, {
eventType: TRAVEL_ERP_EVENT_TYPE,
payload,
dedupeKey: buildTravelReconciliationSummaryDedupeKey(payload.entityId),
maxAttempts: config.integrationOutboxMaxAttempts,
actorId: input?.actorId ?? null,
requeueOnFailure: input?.requeueOnFailure === true,
});
return {
...enqueued,
eventType: TRAVEL_ERP_EVENT_TYPE,
payload,
};
}
async function findOutboxByKey(prisma, eventType, dedupeKey) {
if (!eventType || !dedupeKey) {
return null;
}
return prisma.integrationOutbox.findUnique({
where: {
event_type_dedupe_key: {
event_type: eventType,
dedupe_key: dedupeKey,
},
},
});
}
async function findBindingByLocalId(prisma, entityType, localId) {
return prisma.integrationBinding.findUnique({
where: {
provider_entity_type_local_id: {
provider: "erp",
entity_type: entityType,
local_id: localId,
},
},
});
}
async function loadTravelReconciliationErpContext(prisma, batchId) {
return prisma.travelReconciliationBatch.findUnique({
where: { id: batchId },
select: TRAVEL_RECONCILIATION_ERP_SELECT,
});
}
function resolveTrackStatus({ eligible, outbox, payableBinding, settlementBinding, gateEnabled }) {
if (outbox?.status) {
return outbox.status;
}
if (payableBinding && settlementBinding) {
return "succeeded";
}
if (!eligible) {
return TRAVEL_ERP_TRACK_STATUSES.notTriggered;
}
if (!gateEnabled) {
return TRAVEL_ERP_TRACK_STATUSES.skipped;
}
return TRAVEL_ERP_TRACK_STATUSES.notTriggered;
}
async function buildTravelReconciliationErpTrack(prisma, config, batchId, options = {}) {
const batch = options.context ?? (await loadTravelReconciliationErpContext(prisma, batchId));
if (!batch) {
return null;
}
const canManage = Boolean(options.canManage);
const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true);
const dedupeKey = buildTravelReconciliationSummaryDedupeKey(batch.id);
const [outboxRow, payableBindingRow, settlementBindingRow] = await Promise.all([
findOutboxByKey(prisma, TRAVEL_ERP_EVENT_TYPE, dedupeKey),
findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.payableSummary, batch.id),
findBindingByLocalId(prisma, TRAVEL_ERP_BINDING_TYPES.settlementBatch, batch.id),
]);
const eligible = batch.status === "reconciled";
const reason = eligible ? null : "travel_reconciliation_batch_not_reconciled";
const outbox = mapIntegrationOutboxState(outboxRow);
const payableBinding = mapIntegrationBinding(payableBindingRow);
const settlementBinding = mapIntegrationBinding(settlementBindingRow);
return {
batch: {
id: batch.id,
batchNo: batch.batch_no,
status: batch.status,
periodStart: toDateOnly(batch.period_start),
periodEnd: toDateOnly(batch.period_end),
legalEntity: batch.legal_entity
? {
id: batch.legal_entity.id,
code: batch.legal_entity.code,
name: batch.legal_entity.name,
}
: null,
costCenter: batch.cost_center
? {
id: batch.cost_center.id,
code: batch.cost_center.code,
name: batch.cost_center.name,
}
: null,
},
stage: {
summaryExport: {
eligible,
reason,
status: resolveTrackStatus({
eligible,
outbox,
payableBinding: payableBindingRow,
settlementBinding: settlementBindingRow,
gateEnabled,
}),
preview: buildTravelReconciliationSummaryPayload({ batch }),
outbox,
bindings: {
payableSummary: redactBinding(payableBinding, canManage),
settlementBatch: redactBinding(settlementBinding, canManage),
},
},
},
permissions: {
canRetry: Boolean(canManage && gateEnabled && eligible),
canSeeExternalRefs: canManage,
},
};
}
module.exports = {
TRAVEL_ERP_BINDING_TYPES,
TRAVEL_ERP_EVENT_TYPE,
TRAVEL_ERP_EXPORT_TYPE,
TRAVEL_ERP_TRACK_STATUSES,
TRAVEL_RECONCILIATION_ERP_SELECT,
buildTravelReconciliationErpTrack,
buildTravelReconciliationSummaryDedupeKey,
buildTravelReconciliationSummaryPayload,
enqueueTravelReconciliationSummaryExport,
loadTravelReconciliationErpContext,
};
+738
查看文件
@@ -0,0 +1,738 @@
const { AppError } = require("../utils/AppError");
const TRAVEL_RECONCILIATION_BATCH_STATUSES = Object.freeze({
draft: "draft",
reconciled: "reconciled",
});
const TRAVEL_RECONCILIATION_ITEM_KINDS = Object.freeze({
order: "order",
orphanTransaction: "orphan_transaction",
});
const TRAVEL_RECONCILIATION_MATCH_STATUSES = Object.freeze({
matched: "matched",
amountMismatch: "amount_mismatch",
currencyMismatch: "currency_mismatch",
missingTransaction: "missing_transaction",
duplicateCharge: "duplicate_charge",
orphanTransaction: "orphan_transaction",
reversedAfterPayment: "reversed_after_payment",
});
const TRAVEL_RECONCILIATION_RESOLUTION_STATUSES = Object.freeze({
pending: "pending",
confirmed: "confirmed",
});
function buildValidationError(details, statusCode = 409) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function toMoneyNumber(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return 0;
}
return Number(numeric.toFixed(2));
}
function toMoneyFixed(value) {
return toMoneyNumber(value).toFixed(2);
}
function toDateOnlyString(value) {
if (!value) {
return null;
}
try {
return new Date(value).toISOString().slice(0, 10);
} catch {
return null;
}
}
function normalizeSummaryJson(value) {
const summary = value && typeof value === "object" ? value : {};
return {
totalOrders: Number(summary.totalOrders ?? 0),
totalTransactions: Number(summary.totalTransactions ?? 0),
matchedItems: Number(summary.matchedItems ?? 0),
pendingItems: Number(summary.pendingItems ?? 0),
anomalyItems: Number(summary.anomalyItems ?? 0),
totalOrderAmount: toMoneyFixed(summary.totalOrderAmount),
totalSettledAmount: toMoneyFixed(summary.totalSettledAmount),
totalReversedAmount: toMoneyFixed(summary.totalReversedAmount),
matchRate: toMoneyFixed(summary.matchRate),
};
}
function isSettledCharge(transaction) {
return transaction?.txn_type === "charge" && transaction?.status === "settled";
}
function isReversedTransaction(transaction) {
return ["refund", "reversal"].includes(String(transaction?.txn_type ?? "")) || transaction?.status === "reversed";
}
function sumTransactionAmounts(items, predicate) {
return items.reduce((total, item) => {
if (!predicate(item)) {
return total;
}
return toMoneyNumber(total + toMoneyNumber(item.amount));
}, 0);
}
function buildReasonDetail(matchStatus, order, settledCharges, reversedTransactions) {
if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) {
return `订单 ${order.external_order_no} 与已结算交易精确匹配。`;
}
if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction) {
return `订单 ${order.external_order_no} 未找到已结算的 charge 交易。`;
}
if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge) {
return `订单 ${order.external_order_no} 关联到 ${settledCharges.length} 条已结算 charge 交易,无法判定唯一匹配。`;
}
if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch) {
return `订单 ${order.external_order_no} 与交易 ${settledCharges[0]?.external_txn_id ?? "-"} 币种不一致。`;
}
if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch) {
return `订单 ${order.external_order_no} 与交易 ${settledCharges[0]?.external_txn_id ?? "-"} 金额不一致。`;
}
if (matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment) {
return `订单 ${order.external_order_no} 在支付后存在 ${reversedTransactions.length} 条 refund/reversal 交易。`;
}
return `订单 ${order.external_order_no} 存在待人工确认的差异。`;
}
function analyzeOrderTransactions(order, orderTransactions = []) {
const settledCharges = orderTransactions.filter(isSettledCharge);
const reversedTransactions = orderTransactions.filter(isReversedTransaction);
const settledAmount = sumTransactionAmounts(settledCharges, () => true);
const primaryTransaction = settledCharges[0] ?? reversedTransactions[0] ?? null;
const exactMatch =
settledCharges.length === 1 &&
String(order.currency ?? "").toUpperCase() === String(primaryTransaction?.currency ?? "").toUpperCase() &&
toMoneyFixed(order.amount) === toMoneyFixed(primaryTransaction?.amount);
let matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction;
if (reversedTransactions.length > 0) {
matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment;
} else if (settledCharges.length === 0) {
matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction;
} else if (settledCharges.length > 1) {
matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge;
} else if (String(order.currency ?? "").toUpperCase() !== String(primaryTransaction?.currency ?? "").toUpperCase()) {
matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch;
} else if (!exactMatch) {
matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch;
} else {
matchStatus = TRAVEL_RECONCILIATION_MATCH_STATUSES.matched;
}
const anomalyCode = matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched ? null : matchStatus;
const resolutionStatus =
matchStatus === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched
? TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed
: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending;
return {
primaryTransaction,
matchStatus,
anomalyCode,
resolutionStatus,
transactionAmount: toMoneyFixed(settledAmount || primaryTransaction?.amount),
matchedTransactionIds: orderTransactions.map((item) => item.id),
reasonDetail: buildReasonDetail(matchStatus, order, settledCharges, reversedTransactions),
};
}
function buildInvoiceStatsMap(invoices = []) {
const stats = new Map();
for (const invoice of invoices) {
if (!invoice.travel_order_id) {
continue;
}
const current = stats.get(invoice.travel_order_id) ?? {
count: 0,
amount: 0,
};
current.count += 1;
current.amount = toMoneyNumber(current.amount + toMoneyNumber(invoice.total_amount));
stats.set(invoice.travel_order_id, current);
}
return stats;
}
function buildOrderItem({ order, orderTransactions = [], invoiceStatsMap, actorId, now }) {
const analysis = analyzeOrderTransactions(order, orderTransactions);
const invoiceStats = invoiceStatsMap.get(order.id) ?? { count: 0, amount: 0 };
const primaryTransaction = analysis.primaryTransaction;
const autoConfirmed = analysis.resolutionStatus === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed;
return {
item_kind: TRAVEL_RECONCILIATION_ITEM_KINDS.order,
travel_request_id: order.travel_request_id ?? null,
travel_order_id: order.id,
card_transaction_id: primaryTransaction?.id ?? null,
match_status: analysis.matchStatus,
resolution_status: analysis.resolutionStatus,
order_amount: toMoneyFixed(order.amount),
transaction_amount: analysis.transactionAmount,
currency: order.currency ?? primaryTransaction?.currency ?? "CNY",
anomaly_code: analysis.anomalyCode,
snapshot_json: {
requestNo: order.travel_request?.request_no ?? null,
externalOrderNo: order.external_order_no,
externalTxnId: primaryTransaction?.external_txn_id ?? null,
travelOrderStatus: order.status,
transactionStatus: primaryTransaction?.status ?? null,
linkedReimbursementId: order.linked_reimbursement_id ?? null,
boundInvoiceCount: invoiceStats.count,
boundInvoiceAmount: toMoneyFixed(invoiceStats.amount),
matchedTransactionIds: analysis.matchedTransactionIds,
reasonDetail: analysis.reasonDetail,
},
confirmed_at: autoConfirmed ? now : null,
confirmed_by: autoConfirmed ? actorId : null,
confirm_note: null,
};
}
function buildOrphanTransactionItem({ transaction, actorId, now }) {
return {
item_kind: TRAVEL_RECONCILIATION_ITEM_KINDS.orphanTransaction,
travel_request_id: transaction.travel_request_id ?? null,
travel_order_id: transaction.travel_order_id ?? null,
card_transaction_id: transaction.id,
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction,
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
order_amount: null,
transaction_amount: toMoneyFixed(transaction.amount),
currency: transaction.currency ?? "CNY",
anomaly_code: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction,
snapshot_json: {
requestNo: transaction.travel_request?.request_no ?? null,
externalOrderNo: transaction.travel_order?.external_order_no ?? null,
externalTxnId: transaction.external_txn_id,
travelOrderStatus: transaction.travel_order?.status ?? null,
transactionStatus: transaction.status,
linkedReimbursementId: transaction.travel_order?.linked_reimbursement_id ?? null,
boundInvoiceCount: 0,
boundInvoiceAmount: toMoneyFixed(0),
matchedTransactionIds: [transaction.id],
reasonDetail: `交易 ${transaction.external_txn_id} 未匹配到本批次范围内的差旅订单。`,
},
confirmed_at: null,
confirmed_by: null,
confirm_note: null,
};
}
function buildTravelReconciliationSummary({ items, orders, transactions }) {
const matchedItems = items.filter((item) => item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched).length;
const pendingItems = items.filter((item) => item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending).length;
const anomalyItems = items.length - matchedItems;
const totalOrderAmount = orders.reduce((total, item) => toMoneyNumber(total + toMoneyNumber(item.amount)), 0);
const totalSettledAmount = transactions.reduce((total, item) => {
if (!isSettledCharge(item)) {
return total;
}
return toMoneyNumber(total + toMoneyNumber(item.amount));
}, 0);
const totalReversedAmount = transactions.reduce((total, item) => {
if (!isReversedTransaction(item)) {
return total;
}
return toMoneyNumber(total + toMoneyNumber(item.amount));
}, 0);
const matchRate = items.length ? (matchedItems / items.length) * 100 : 0;
return {
totalOrders: orders.length,
totalTransactions: transactions.length,
matchedItems,
pendingItems,
anomalyItems,
totalOrderAmount: toMoneyFixed(totalOrderAmount),
totalSettledAmount: toMoneyFixed(totalSettledAmount),
totalReversedAmount: toMoneyFixed(totalReversedAmount),
matchRate: toMoneyFixed(matchRate),
};
}
function buildReconciliationItemsSnapshot({ orders = [], transactions = [], invoices = [], actorId = null, now = new Date() }) {
const invoiceStatsMap = buildInvoiceStatsMap(invoices);
const ordersById = new Map(orders.map((item) => [item.id, item]));
const transactionsByOrderId = new Map();
for (const transaction of transactions) {
if (!transaction.travel_order_id) {
continue;
}
const list = transactionsByOrderId.get(transaction.travel_order_id) ?? [];
list.push(transaction);
transactionsByOrderId.set(transaction.travel_order_id, list);
}
const items = orders.map((order) =>
buildOrderItem({
order,
orderTransactions: transactionsByOrderId.get(order.id) ?? [],
invoiceStatsMap,
actorId,
now,
}),
);
const orphanTransactions = transactions.filter(
(transaction) => !transaction.travel_order_id || !ordersById.has(transaction.travel_order_id),
);
for (const transaction of orphanTransactions) {
items.push(buildOrphanTransactionItem({ transaction, actorId, now }));
}
return {
items,
summary: buildTravelReconciliationSummary({ items, orders, transactions }),
};
}
function buildOrderWhere({ legalEntityId, costCenterId, periodStart, periodEnd, periodEndExclusive }) {
return {
status: "paid",
travel_request: {
legal_entity_id: legalEntityId,
...(costCenterId ? { cost_center_id: costCenterId } : {}),
},
OR: [
{
payment_date: {
gte: periodStart,
lte: periodEnd,
},
},
{
AND: [
{ payment_date: null },
{ paid_at: { gte: periodStart, lt: periodEndExclusive } },
],
},
],
};
}
function buildTransactionWhere({ legalEntityId, costCenterId, periodStart, periodEndExclusive }) {
return {
status: { in: ["settled", "reversed"] },
travel_request: {
legal_entity_id: legalEntityId,
...(costCenterId ? { cost_center_id: costCenterId } : {}),
},
occurred_at: {
gte: periodStart,
lt: periodEndExclusive,
},
};
}
const ORDER_SCOPE_SELECT = {
id: true,
source: true,
external_order_no: true,
linked_reimbursement_id: true,
travel_request_id: true,
amount: true,
currency: true,
status: true,
payment_date: true,
paid_at: true,
travel_request: {
select: {
id: true,
request_no: true,
legal_entity_id: true,
cost_center_id: true,
},
},
};
const TRANSACTION_SCOPE_SELECT = {
id: true,
source: true,
external_txn_id: true,
travel_request_id: true,
travel_order_id: true,
amount: true,
currency: true,
txn_type: true,
status: true,
occurred_at: true,
travel_request: {
select: {
id: true,
request_no: true,
},
},
travel_order: {
select: {
id: true,
external_order_no: true,
linked_reimbursement_id: true,
status: true,
},
},
};
async function loadReconciliationScope(prisma, { legalEntityId, costCenterId = null, periodStart, periodEnd }) {
const normalizedStart = new Date(`${toDateOnlyString(periodStart)}T00:00:00.000Z`);
const normalizedEnd = new Date(`${toDateOnlyString(periodEnd)}T00:00:00.000Z`);
const periodEndExclusive = new Date(normalizedEnd.getTime() + 24 * 60 * 60 * 1000);
const [orders, transactions] = await Promise.all([
prisma.travelOrder.findMany({
where: buildOrderWhere({
legalEntityId,
costCenterId,
periodStart: normalizedStart,
periodEnd: normalizedEnd,
periodEndExclusive,
}),
orderBy: [{ payment_date: "asc" }, { paid_at: "asc" }, { created_at: "asc" }],
select: ORDER_SCOPE_SELECT,
}),
prisma.cardTransaction.findMany({
where: buildTransactionWhere({
legalEntityId,
costCenterId,
periodStart: normalizedStart,
periodEndExclusive,
}),
orderBy: [{ occurred_at: "asc" }, { created_at: "asc" }],
select: TRANSACTION_SCOPE_SELECT,
}),
]);
const orderIds = orders.map((item) => item.id);
const invoices = orderIds.length
? await prisma.invoicePool.findMany({
where: { travel_order_id: { in: orderIds } },
select: {
id: true,
travel_order_id: true,
total_amount: true,
},
})
: [];
return {
orders,
transactions,
invoices,
periodStart: normalizedStart,
periodEnd: normalizedEnd,
};
}
function mapBatchSummary(batch) {
return {
id: batch.id,
batchNo: batch.batch_no,
legalEntityId: batch.legal_entity_id,
costCenterId: batch.cost_center_id ?? null,
periodStart: toDateOnlyString(batch.period_start),
periodEnd: toDateOnlyString(batch.period_end),
status: batch.status,
summary: normalizeSummaryJson(batch.summary_json),
confirmedAt: batch.confirmed_at?.toISOString?.() ?? null,
confirmedBy: batch.confirmed_by ?? null,
createdAt: batch.created_at?.toISOString?.() ?? null,
updatedAt: batch.updated_at?.toISOString?.() ?? null,
legalEntity: batch.legal_entity
? {
id: batch.legal_entity.id,
code: batch.legal_entity.code,
name: batch.legal_entity.name,
}
: null,
costCenter: batch.cost_center
? {
id: batch.cost_center.id,
code: batch.cost_center.code,
name: batch.cost_center.name,
legalEntityId: batch.cost_center.legal_entity_id,
}
: null,
confirmedByEmployee: batch.confirmed_by_employee
? {
id: batch.confirmed_by_employee.id,
employeeNo: batch.confirmed_by_employee.employee_no,
name: batch.confirmed_by_employee.name,
}
: null,
};
}
function mapBatchItem(item, { canManagePayments = false, batchStatus = TRAVEL_RECONCILIATION_BATCH_STATUSES.draft } = {}) {
const snapshot = item.snapshot_json && typeof item.snapshot_json === "object" ? item.snapshot_json : {};
return {
id: item.id,
itemKind: item.item_kind,
travelRequestId: item.travel_request_id ?? null,
travelOrderId: item.travel_order_id ?? null,
cardTransactionId: item.card_transaction_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
orderAmount: item.order_amount?.toString?.() ?? null,
transactionAmount: item.transaction_amount?.toString?.() ?? null,
currency: item.currency,
anomalyCode: item.anomaly_code ?? null,
confirmedAt: item.confirmed_at?.toISOString?.() ?? null,
confirmedBy: item.confirmed_by ?? null,
confirmNote: item.confirm_note ?? null,
snapshot: {
requestNo: snapshot.requestNo ?? null,
externalOrderNo: snapshot.externalOrderNo ?? null,
externalTxnId: snapshot.externalTxnId ?? null,
travelOrderStatus: snapshot.travelOrderStatus ?? null,
transactionStatus: snapshot.transactionStatus ?? null,
linkedReimbursementId: snapshot.linkedReimbursementId ?? null,
boundInvoiceCount: Number(snapshot.boundInvoiceCount ?? 0),
boundInvoiceAmount: toMoneyFixed(snapshot.boundInvoiceAmount),
matchedTransactionIds: Array.isArray(snapshot.matchedTransactionIds) ? snapshot.matchedTransactionIds : [],
reasonDetail: snapshot.reasonDetail ?? null,
},
travelRequest: item.travel_request
? {
id: item.travel_request.id,
requestNo: item.travel_request.request_no ?? null,
}
: null,
travelOrder: item.travel_order
? {
id: item.travel_order.id,
externalOrderNo: item.travel_order.external_order_no,
status: item.travel_order.status,
}
: null,
cardTransaction: item.card_transaction
? {
id: item.card_transaction.id,
externalTxnId: item.card_transaction.external_txn_id,
status: item.card_transaction.status,
txnType: item.card_transaction.txn_type,
}
: null,
actions: {
canConfirm:
canManagePayments &&
batchStatus === TRAVEL_RECONCILIATION_BATCH_STATUSES.draft &&
item.match_status !== TRAVEL_RECONCILIATION_MATCH_STATUSES.matched &&
item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
canReopen:
canManagePayments &&
batchStatus === TRAVEL_RECONCILIATION_BATCH_STATUSES.draft &&
item.match_status !== TRAVEL_RECONCILIATION_MATCH_STATUSES.matched &&
item.resolution_status === TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed,
},
};
}
const BATCH_SELECT = {
id: true,
batch_no: true,
legal_entity_id: true,
cost_center_id: true,
period_start: true,
period_end: true,
status: true,
summary_json: true,
confirmed_at: true,
confirmed_by: true,
created_at: true,
updated_at: true,
legal_entity: {
select: {
id: true,
code: true,
name: true,
},
},
cost_center: {
select: {
id: true,
code: true,
name: true,
legal_entity_id: true,
},
},
confirmed_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
};
const BATCH_DETAIL_SELECT = {
...BATCH_SELECT,
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
item_kind: true,
travel_request_id: true,
travel_order_id: true,
card_transaction_id: true,
match_status: true,
resolution_status: true,
order_amount: true,
transaction_amount: true,
currency: true,
anomaly_code: true,
snapshot_json: true,
confirmed_at: true,
confirmed_by: true,
confirm_note: true,
created_at: true,
updated_at: true,
travel_request: {
select: {
id: true,
request_no: true,
},
},
travel_order: {
select: {
id: true,
external_order_no: true,
status: true,
},
},
card_transaction: {
select: {
id: true,
external_txn_id: true,
status: true,
txn_type: true,
},
},
},
},
};
async function listTravelReconciliationBatches(prisma, query) {
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}),
...(query.costCenterId ? { cost_center_id: query.costCenterId } : {}),
...(query.periodStart ? { period_end: { gte: query.periodStart } } : {}),
...(query.periodEnd ? { period_start: { lte: query.periodEnd } } : {}),
};
const [items, total] = await Promise.all([
prisma.travelReconciliationBatch.findMany({
where,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
skip,
take: query.pageSize,
select: BATCH_SELECT,
}),
prisma.travelReconciliationBatch.count({ where }),
]);
return {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map(mapBatchSummary),
};
}
async function loadTravelReconciliationBatchDetail(prisma, batchId, options = {}) {
const batch = await prisma.travelReconciliationBatch.findUnique({
where: { id: batchId },
select: BATCH_DETAIL_SELECT,
});
if (!batch) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return {
...mapBatchSummary(batch),
items: batch.items.map((item) => mapBatchItem(item, { canManagePayments: options.canManagePayments, batchStatus: batch.status })),
permissions: {
canManagePayments: Boolean(options.canManagePayments),
canRetryErp: Boolean(options.canRetryErp),
},
};
}
function ensureDraftBatch(batch) {
if (batch.status !== TRAVEL_RECONCILIATION_BATCH_STATUSES.draft) {
throw buildValidationError({ batch: "reconciliation_batch_not_editable" });
}
}
function ensurePendingReconciliationItem(item) {
if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending) {
throw buildValidationError({ item: "reconciliation_item_not_pending" });
}
}
function ensureConfirmedReconciliationItem(item) {
if (item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed) {
throw buildValidationError({ item: "reconciliation_item_not_confirmed" });
}
}
function ensureReopenableReconciliationItem(item) {
if (item.match_status === TRAVEL_RECONCILIATION_MATCH_STATUSES.matched) {
throw buildValidationError({ item: "matched_item_not_reopenable" });
}
}
function ensureBatchFinalizable(items = []) {
if (items.some((item) => item.resolution_status !== TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed)) {
throw buildValidationError({ batch: "reconciliation_batch_has_pending_items" });
}
}
module.exports = {
TRAVEL_RECONCILIATION_BATCH_STATUSES,
TRAVEL_RECONCILIATION_ITEM_KINDS,
TRAVEL_RECONCILIATION_MATCH_STATUSES,
TRAVEL_RECONCILIATION_RESOLUTION_STATUSES,
BATCH_SELECT,
BATCH_DETAIL_SELECT,
buildReconciliationItemsSnapshot,
buildTravelReconciliationSummary,
buildValidationError,
ensureBatchFinalizable,
ensureConfirmedReconciliationItem,
ensureDraftBatch,
ensurePendingReconciliationItem,
ensureReopenableReconciliationItem,
listTravelReconciliationBatches,
loadReconciliationScope,
loadTravelReconciliationBatchDetail,
mapBatchItem,
mapBatchSummary,
normalizeSummaryJson,
toMoneyFixed,
toMoneyNumber,
};