文件
hyb-oa/server/src/routes/travelReconciliation.js
T

739 行
22 KiB
JavaScript

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;