feat(P19ERP2-1,P19ERP2-2): 实现采购ERP凭证与摘要出站服务
这个提交包含在:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "integration_bindings"
|
||||||
|
ALTER COLUMN "entity_type" TYPE VARCHAR(64);
|
||||||
@@ -1208,7 +1208,7 @@ model IntegrationOutbox {
|
|||||||
model IntegrationBinding {
|
model IntegrationBinding {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
provider String @db.VarChar(32)
|
provider String @db.VarChar(32)
|
||||||
entity_type String @db.VarChar(32)
|
entity_type String @db.VarChar(64)
|
||||||
local_id String @db.Uuid
|
local_id String @db.Uuid
|
||||||
external_id String? @db.VarChar(128)
|
external_id String? @db.VarChar(128)
|
||||||
external_code String? @db.VarChar(128)
|
external_code String? @db.VarChar(128)
|
||||||
|
|||||||
@@ -109,8 +109,12 @@ function getConfig() {
|
|||||||
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
|
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
|
||||||
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
|
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
|
||||||
erpJournalExportEndpoint: process.env.ERP_JOURNAL_EXPORT_ENDPOINT ?? "",
|
erpJournalExportEndpoint: process.env.ERP_JOURNAL_EXPORT_ENDPOINT ?? "",
|
||||||
|
erpProcurementExportEndpoint: process.env.ERP_PROCUREMENT_EXPORT_ENDPOINT ?? "",
|
||||||
erpMasterDataEndpoint: process.env.ERP_MASTER_DATA_ENDPOINT ?? "",
|
erpMasterDataEndpoint: process.env.ERP_MASTER_DATA_ENDPOINT ?? "",
|
||||||
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
|
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
|
||||||
|
erpProcurementTimeoutMs: Number(
|
||||||
|
process.env.ERP_PROCUREMENT_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000,
|
||||||
|
),
|
||||||
erpMasterDataTimeoutMs: Number(
|
erpMasterDataTimeoutMs: Number(
|
||||||
process.env.ERP_MASTER_DATA_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000,
|
process.env.ERP_MASTER_DATA_TIMEOUT_MS ?? process.env.ERP_SYNC_TIMEOUT_MS ?? 5000,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ async function runIntegrationOutboxOnce(prisma, config, logger) {
|
|||||||
reimbursementId:
|
reimbursementId:
|
||||||
item.payload?.reimbursementId ??
|
item.payload?.reimbursementId ??
|
||||||
(item.payload?.entityType === 'reimbursement_form' ? item.payload?.entityId ?? null : null),
|
(item.payload?.entityType === 'reimbursement_form' ? item.payload?.entityId ?? null : null),
|
||||||
|
purchaseRequestId:
|
||||||
|
item.payload?.purchaseRequestId ??
|
||||||
|
item.payload?.references?.purchaseRequestId ??
|
||||||
|
(item.payload?.entityType === 'purchase_request' ? item.payload?.entityId ?? null : null),
|
||||||
|
purchaseOrderArchiveId:
|
||||||
|
item.payload?.purchaseOrderArchiveId ??
|
||||||
|
item.payload?.references?.purchaseOrderArchiveId ??
|
||||||
|
(item.payload?.entityType === 'purchase_order_archive' ? item.payload?.entityId ?? null : null),
|
||||||
|
journalType: item.payload?.journalType ?? null,
|
||||||
|
poNo: item.payload?.poNo ?? item.payload?.references?.poNo ?? null,
|
||||||
|
contractNo: item.payload?.contractNo ?? item.payload?.references?.contractNo ?? null,
|
||||||
entityType: item.payload?.entityType ?? null,
|
entityType: item.payload?.entityType ?? null,
|
||||||
entityId: item.payload?.entityId ?? null,
|
entityId: item.payload?.entityId ?? null,
|
||||||
channelId: item.payload?.channelId ?? null,
|
channelId: item.payload?.channelId ?? null,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const { signPayload, verifyToken } = require("../utils/signedUrl");
|
|||||||
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
const { parseWorkflowSteps, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
const {
|
||||||
|
PROCUREMENT_ERP_TARGETS,
|
||||||
|
enqueuePurchaseAccrualJournalExport,
|
||||||
|
enqueuePurchaseArchiveSummaryExport,
|
||||||
|
loadPurchaseRequestErpContext,
|
||||||
|
} = require("../services/integrations/erpProcurement");
|
||||||
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
||||||
const { sendNotificationEmailSafe } = require("../services/email");
|
const { sendNotificationEmailSafe } = require("../services/email");
|
||||||
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
|
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
|
||||||
@@ -837,6 +844,116 @@ async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
|
|||||||
return purchaseRequest;
|
return purchaseRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePurchaseArchiveErpSync({
|
||||||
|
tx,
|
||||||
|
config,
|
||||||
|
request,
|
||||||
|
actorId,
|
||||||
|
purchaseRequestId,
|
||||||
|
archiveId,
|
||||||
|
isFirstArchive,
|
||||||
|
loadContext = loadPurchaseRequestErpContext,
|
||||||
|
evaluateGate = evaluateFeatureFlag,
|
||||||
|
enqueueAccrual = enqueuePurchaseAccrualJournalExport,
|
||||||
|
enqueueSummary = enqueuePurchaseArchiveSummaryExport,
|
||||||
|
}) {
|
||||||
|
const context = await loadContext(tx, purchaseRequestId);
|
||||||
|
if (!context?.purchaseRequest?.order_archive) {
|
||||||
|
return { skipped: true, reason: 'purchase_order_archive_missing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseRequest = context.purchaseRequest;
|
||||||
|
const archive = purchaseRequest.order_archive;
|
||||||
|
const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null;
|
||||||
|
const gate = await evaluateGate({
|
||||||
|
prisma: tx,
|
||||||
|
config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
departmentId,
|
||||||
|
strictDepartment: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gate.enabled) {
|
||||||
|
const skipTargets = isFirstArchive
|
||||||
|
? [PROCUREMENT_ERP_TARGETS.accrualJournal, PROCUREMENT_ERP_TARGETS.archiveSummary]
|
||||||
|
: [PROCUREMENT_ERP_TARGETS.archiveSummary];
|
||||||
|
|
||||||
|
for (const erpTarget of skipTargets) {
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId,
|
||||||
|
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
|
||||||
|
targetType: 'purchase_order_archive',
|
||||||
|
targetId: archiveId,
|
||||||
|
extra: {
|
||||||
|
erpTarget,
|
||||||
|
eventType: erpTarget === PROCUREMENT_ERP_TARGETS.archiveSummary ? 'erp.procurement.export' : 'erp.journal.export',
|
||||||
|
purchaseRequestId,
|
||||||
|
purchaseOrderArchiveId: archiveId,
|
||||||
|
gateReason: gate.reason,
|
||||||
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
departmentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skipped: true, gate, purchaseRequest, archive };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueuedItems = [];
|
||||||
|
if (isFirstArchive) {
|
||||||
|
const accrual = await enqueueAccrual(tx, config, {
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
occurredAt: archive.archived_at ?? new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: false,
|
||||||
|
});
|
||||||
|
enqueuedItems.push(accrual);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await enqueueSummary(tx, config, {
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
archivedAt: archive.archived_at ?? new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: false,
|
||||||
|
});
|
||||||
|
enqueuedItems.push(summary);
|
||||||
|
|
||||||
|
for (const item of enqueuedItems) {
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId,
|
||||||
|
action: 'ERP_SYNC_ENQUEUED',
|
||||||
|
targetType: 'purchase_order_archive',
|
||||||
|
targetId: archiveId,
|
||||||
|
extra: {
|
||||||
|
erpTarget: item.erpTarget,
|
||||||
|
eventType: item.eventType,
|
||||||
|
purchaseRequestId,
|
||||||
|
purchaseOrderArchiveId: archiveId,
|
||||||
|
outboxId: item.id,
|
||||||
|
duplicated: item.duplicated,
|
||||||
|
requeued: item.requeued,
|
||||||
|
gateReason: gate.reason,
|
||||||
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
departmentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skipped: false,
|
||||||
|
gate,
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
items: enqueuedItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function purchaseRequestRoutes(fastify) {
|
async function purchaseRequestRoutes(fastify) {
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/purchase-requests",
|
"/purchase-requests",
|
||||||
@@ -1527,6 +1644,11 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
throw buildValidationError({ status: "only_approved_archivable" }, 409);
|
throw buildValidationError({ status: "only_approved_archivable" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingArchive = await tx.purchaseOrderArchive.findUnique({
|
||||||
|
where: { purchase_request_id: purchaseRequestId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
const archive = await tx.purchaseOrderArchive.upsert({
|
const archive = await tx.purchaseOrderArchive.upsert({
|
||||||
where: { purchase_request_id: purchaseRequestId },
|
where: { purchase_request_id: purchaseRequestId },
|
||||||
update: {
|
update: {
|
||||||
@@ -1575,6 +1697,23 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlePurchaseArchiveErpSync({
|
||||||
|
tx,
|
||||||
|
config: fastify.config,
|
||||||
|
request,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
purchaseRequestId,
|
||||||
|
archiveId: archive.id,
|
||||||
|
isFirstArchive: !existingArchive,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, purchaseRequestId, purchaseOrderArchiveId: archive.id },
|
||||||
|
"Failed to enqueue purchase archive ERP events",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
archiveOrder: mapPurchaseOrderArchive(archive),
|
archiveOrder: mapPurchaseOrderArchive(archive),
|
||||||
};
|
};
|
||||||
@@ -1942,3 +2081,8 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = purchaseRequestRoutes;
|
module.exports = purchaseRequestRoutes;
|
||||||
|
|
||||||
|
module.exports.__testables = {
|
||||||
|
handlePurchaseArchiveErpSync,
|
||||||
|
parseArchiveBody,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS, resolveEntityPermission } = require("../constants/permissions");
|
||||||
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
|
||||||
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
|
const { consumeReservedLeave, releaseReservedLeave, formatDateOnly } = require("../services/leave");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
const { confirmBudgetForEntity, releaseReservedBudgetIfAny } = require("../services/purchaseBudget");
|
||||||
|
const {
|
||||||
|
PROCUREMENT_ERP_TARGETS,
|
||||||
|
enqueuePurchaseCommitmentJournalExport,
|
||||||
|
loadPurchaseRequestErpContext,
|
||||||
|
} = require("../services/integrations/erpProcurement");
|
||||||
const {
|
const {
|
||||||
createNotifications,
|
createNotifications,
|
||||||
resolveRecipientsForAssignee,
|
resolveRecipientsForAssignee,
|
||||||
@@ -97,6 +104,89 @@ function parseTaskId(taskId) {
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePurchaseApprovalErpSync({
|
||||||
|
tx,
|
||||||
|
config,
|
||||||
|
request,
|
||||||
|
actorId,
|
||||||
|
purchaseRequestId,
|
||||||
|
loadContext = loadPurchaseRequestErpContext,
|
||||||
|
evaluateGate = evaluateFeatureFlag,
|
||||||
|
enqueueCommitment = enqueuePurchaseCommitmentJournalExport,
|
||||||
|
}) {
|
||||||
|
const context = await loadContext(tx, purchaseRequestId);
|
||||||
|
if (!context?.purchaseRequest) {
|
||||||
|
return { skipped: true, reason: 'purchase_request_not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseRequest = context.purchaseRequest;
|
||||||
|
const departmentId = purchaseRequest.department_id ?? request.employee.department_id ?? null;
|
||||||
|
const gate = await evaluateGate({
|
||||||
|
prisma: tx,
|
||||||
|
config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
departmentId,
|
||||||
|
strictDepartment: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gate.enabled) {
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId,
|
||||||
|
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
|
||||||
|
targetType: 'purchase_request',
|
||||||
|
targetId: purchaseRequest.id,
|
||||||
|
extra: {
|
||||||
|
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
purchaseRequestId: purchaseRequest.id,
|
||||||
|
purchaseOrderArchiveId: null,
|
||||||
|
gateReason: gate.reason,
|
||||||
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
departmentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { skipped: true, gate, purchaseRequest };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueued = await enqueueCommitment(tx, config, {
|
||||||
|
purchaseRequest,
|
||||||
|
occurredAt: context.approvalOccurredAt ?? new Date(),
|
||||||
|
actorId,
|
||||||
|
requeueOnFailure: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId,
|
||||||
|
action: 'ERP_SYNC_ENQUEUED',
|
||||||
|
targetType: 'purchase_request',
|
||||||
|
targetId: purchaseRequest.id,
|
||||||
|
extra: {
|
||||||
|
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
|
||||||
|
eventType: enqueued.eventType,
|
||||||
|
purchaseRequestId: purchaseRequest.id,
|
||||||
|
purchaseOrderArchiveId: null,
|
||||||
|
outboxId: enqueued.id,
|
||||||
|
duplicated: enqueued.duplicated,
|
||||||
|
requeued: enqueued.requeued,
|
||||||
|
gateReason: gate.reason,
|
||||||
|
featureFlag: FEATURE_FLAG_KEYS.erpSync,
|
||||||
|
departmentId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
skipped: false,
|
||||||
|
gate,
|
||||||
|
purchaseRequest,
|
||||||
|
enqueued,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseTaskListQuery(query) {
|
function parseTaskListQuery(query) {
|
||||||
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -1372,6 +1462,21 @@ async function workflowRoutes(fastify) {
|
|||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
note: "approved_confirm_purchase_budget",
|
note: "approved_confirm_purchase_budget",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlePurchaseApprovalErpSync({
|
||||||
|
tx,
|
||||||
|
config: fastify.config,
|
||||||
|
request,
|
||||||
|
actorId: request.employee.id,
|
||||||
|
purchaseRequestId: workflowInstance.entity_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(
|
||||||
|
{ err: error, purchaseRequestId: workflowInstance.entity_id },
|
||||||
|
"Failed to enqueue purchase approval ERP events",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.workflowHistory.create({
|
await tx.workflowHistory.create({
|
||||||
@@ -1905,5 +2010,6 @@ async function workflowRoutes(fastify) {
|
|||||||
module.exports = workflowRoutes;
|
module.exports = workflowRoutes;
|
||||||
|
|
||||||
module.exports.__testables = {
|
module.exports.__testables = {
|
||||||
|
handlePurchaseApprovalErpSync,
|
||||||
listAllowedActionsForTask,
|
listAllowedActionsForTask,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
const { pushPaidReimbursementToErp, exportJournalToErp } = require('./erp');
|
const {
|
||||||
|
pushPaidReimbursementToErp,
|
||||||
|
exportJournalToErp,
|
||||||
|
exportProcurementToErp,
|
||||||
|
} = require('./erp');
|
||||||
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
const { sendInstantMessageByChannel } = require('../notifications/channels');
|
||||||
|
|
||||||
class IntegrationDispatchError extends Error {
|
class IntegrationDispatchError extends Error {
|
||||||
@@ -22,71 +26,168 @@ function toDispatchError(result) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractExternalId(response) {
|
function extractResponseValue(response, key) {
|
||||||
if (!response) {
|
if (!response || !key) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof response.externalId === 'string' && response.externalId.trim()) {
|
const directValue = response[key];
|
||||||
return response.externalId.trim();
|
if (typeof directValue === 'string' && directValue.trim()) {
|
||||||
|
return directValue.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const bodyValue = response.body?.[key];
|
||||||
typeof response.body?.externalId === 'string' &&
|
if (typeof bodyValue === 'string' && bodyValue.trim()) {
|
||||||
response.body.externalId.trim()
|
return bodyValue.trim();
|
||||||
) {
|
|
||||||
return response.body.externalId.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertErpBindingForPayload(prisma, payload, response) {
|
function createBindingSpec(entityType, localId, externalId, externalCode) {
|
||||||
const entityType = String(payload?.entityType ?? '').trim();
|
const normalizedEntityType = String(entityType ?? '').trim();
|
||||||
const entityId = String(payload?.entityId ?? '').trim();
|
const normalizedLocalId = String(localId ?? '').trim();
|
||||||
if (!prisma || !entityType || !entityId) {
|
if (!normalizedEntityType || !normalizedLocalId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityType: normalizedEntityType,
|
||||||
|
localId: normalizedLocalId,
|
||||||
|
externalId: typeof externalId === 'string' ? externalId.trim() || null : null,
|
||||||
|
externalCode: typeof externalCode === 'string' ? externalCode.trim() || null : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBindingSpecs(eventType, payload, response) {
|
||||||
|
if (eventType === 'erp.reimbursement.paid') {
|
||||||
|
return [
|
||||||
|
createBindingSpec(
|
||||||
|
'reimbursement_journal',
|
||||||
|
payload?.reimbursementId ?? payload?.entityId,
|
||||||
|
extractResponseValue(response, 'externalId'),
|
||||||
|
payload?.formNumber ?? payload?.documentNo ?? null,
|
||||||
|
),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'erp.journal.export') {
|
||||||
|
const journalType = String(payload?.journalType ?? '').trim();
|
||||||
|
if (journalType === 'reimbursement_paid') {
|
||||||
|
return [
|
||||||
|
createBindingSpec(
|
||||||
|
'reimbursement_journal',
|
||||||
|
payload?.entityId,
|
||||||
|
extractResponseValue(response, 'externalId'),
|
||||||
|
payload?.documentNo ?? payload?.formNumber ?? null,
|
||||||
|
),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journalType === 'purchase_request_commitment') {
|
||||||
|
return [
|
||||||
|
createBindingSpec(
|
||||||
|
'purchase_request_commitment_journal',
|
||||||
|
payload?.references?.purchaseRequestId ?? payload?.entityId,
|
||||||
|
extractResponseValue(response, 'externalId'),
|
||||||
|
extractResponseValue(response, 'externalCode') ?? payload?.references?.requestNo ?? payload?.documentNo ?? null,
|
||||||
|
),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journalType === 'purchase_order_accrual') {
|
||||||
|
return [
|
||||||
|
createBindingSpec(
|
||||||
|
'purchase_order_accrual_journal',
|
||||||
|
payload?.references?.purchaseOrderArchiveId ?? payload?.entityId,
|
||||||
|
extractResponseValue(response, 'externalId'),
|
||||||
|
extractResponseValue(response, 'externalCode') ?? payload?.references?.poNo ?? payload?.documentNo ?? null,
|
||||||
|
),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'erp.procurement.export') {
|
||||||
|
const archiveId = payload?.entityId;
|
||||||
|
const specs = [];
|
||||||
|
|
||||||
|
if (archiveId && (payload?.poNo || extractResponseValue(response, 'externalPoId') || extractResponseValue(response, 'externalPoNo'))) {
|
||||||
|
specs.push(
|
||||||
|
createBindingSpec(
|
||||||
|
'purchase_order_po',
|
||||||
|
archiveId,
|
||||||
|
extractResponseValue(response, 'externalPoId'),
|
||||||
|
extractResponseValue(response, 'externalPoNo') ?? payload?.poNo ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archiveId && (payload?.contractNo || extractResponseValue(response, 'externalContractId') || extractResponseValue(response, 'externalContractNo'))) {
|
||||||
|
specs.push(
|
||||||
|
createBindingSpec(
|
||||||
|
'purchase_order_contract',
|
||||||
|
archiveId,
|
||||||
|
extractResponseValue(response, 'externalContractId'),
|
||||||
|
extractResponseValue(response, 'externalContractNo') ?? payload?.contractNo ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return specs.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertErpBindings(prisma, eventType, payload, response) {
|
||||||
|
if (!prisma) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entityType !== 'reimbursement_form') {
|
const specs = resolveBindingSpecs(eventType, payload, response);
|
||||||
|
if (!specs.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.integrationBinding.upsert({
|
for (const spec of specs) {
|
||||||
where: {
|
await prisma.integrationBinding.upsert({
|
||||||
provider_entity_type_local_id: {
|
where: {
|
||||||
provider: 'erp',
|
provider_entity_type_local_id: {
|
||||||
entity_type: 'reimbursement_journal',
|
provider: 'erp',
|
||||||
local_id: entityId,
|
entity_type: spec.entityType,
|
||||||
|
local_id: spec.localId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
create: {
|
||||||
create: {
|
provider: 'erp',
|
||||||
provider: 'erp',
|
entity_type: spec.entityType,
|
||||||
entity_type: 'reimbursement_journal',
|
local_id: spec.localId,
|
||||||
local_id: entityId,
|
external_id: spec.externalId,
|
||||||
external_id: extractExternalId(response),
|
external_code: spec.externalCode,
|
||||||
external_code: payload?.documentNo ?? payload?.formNumber ?? null,
|
last_synced_at: new Date(),
|
||||||
last_synced_at: new Date(),
|
},
|
||||||
},
|
update: {
|
||||||
update: {
|
external_id: spec.externalId,
|
||||||
external_id: extractExternalId(response),
|
external_code: spec.externalCode,
|
||||||
external_code: payload?.documentNo ?? payload?.formNumber ?? null,
|
last_synced_at: new Date(),
|
||||||
last_synced_at: new Date(),
|
},
|
||||||
},
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchErpEvent({ prisma, config, eventType, payload }) {
|
async function dispatchErpEvent({ prisma, config, eventType, payload }) {
|
||||||
const result =
|
const result =
|
||||||
eventType === 'erp.journal.export'
|
eventType === 'erp.procurement.export'
|
||||||
? await exportJournalToErp(config, payload)
|
? await exportProcurementToErp(config, payload)
|
||||||
: await pushPaidReimbursementToErp(config, payload);
|
: eventType === 'erp.journal.export'
|
||||||
|
? await exportJournalToErp(config, payload)
|
||||||
|
: await pushPaidReimbursementToErp(config, payload);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw toDispatchError(result);
|
throw toDispatchError(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
await upsertErpBindingForPayload(prisma, payload, result.response ?? null);
|
await upsertErpBindings(prisma, eventType, payload, result.response ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: 'erp',
|
channel: 'erp',
|
||||||
@@ -96,7 +197,11 @@ async function dispatchErpEvent({ prisma, config, eventType, payload }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||||
if (eventType === 'erp.reimbursement.paid' || eventType === 'erp.journal.export') {
|
if (
|
||||||
|
eventType === 'erp.reimbursement.paid' ||
|
||||||
|
eventType === 'erp.journal.export' ||
|
||||||
|
eventType === 'erp.procurement.export'
|
||||||
|
) {
|
||||||
return dispatchErpEvent({ prisma, config, eventType, payload });
|
return dispatchErpEvent({ prisma, config, eventType, payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ function resolveJournalExportEndpoint(config) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveProcurementExportEndpoint(config) {
|
||||||
|
return (
|
||||||
|
String(config.erpProcurementExportEndpoint ?? '').trim() ||
|
||||||
|
String(config.erpSyncEndpoint ?? '').trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function parseResponseBody(response) {
|
async function parseResponseBody(response) {
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
@@ -192,12 +199,14 @@ async function exportJournalToErp(config, payload) {
|
|||||||
|
|
||||||
const provider = normalizeProvider(config);
|
const provider = normalizeProvider(config);
|
||||||
if (provider === 'mock') {
|
if (provider === 'mock') {
|
||||||
|
const identity = payload?.documentNo ?? payload?.formNumber ?? payload?.entityId ?? payload?.reimbursementId ?? 'UNKNOWN';
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
code: 'mock_synced',
|
code: 'mock_synced',
|
||||||
message: 'mock ERP synced',
|
message: 'mock ERP synced',
|
||||||
response: {
|
response: {
|
||||||
externalId: `ERP-${payload?.documentNo ?? payload?.formNumber ?? payload?.entityId ?? payload?.reimbursementId ?? 'UNKNOWN'}`,
|
externalId: `ERP-${identity}`,
|
||||||
|
externalCode: `ERP-${identity}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -227,6 +236,59 @@ async function exportJournalToErp(config, payload) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportProcurementToErp(config, payload) {
|
||||||
|
if (!config.erpSyncEnabled) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'erp_sync_disabled',
|
||||||
|
message: 'ERP sync disabled',
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = normalizeProvider(config);
|
||||||
|
if (provider === 'mock') {
|
||||||
|
const identity = payload?.poNo ?? payload?.contractNo ?? payload?.entityId ?? 'UNKNOWN';
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
code: 'mock_synced',
|
||||||
|
message: 'mock ERP procurement synced',
|
||||||
|
response: {
|
||||||
|
externalId: `ERP-PROC-${identity}`,
|
||||||
|
externalCode: `ERP-PROC-${identity}`,
|
||||||
|
externalPoId: payload?.poNo ? `ERP-PO-${payload.poNo}` : null,
|
||||||
|
externalPoNo: payload?.poNo ? `ERP-PO-${payload.poNo}` : null,
|
||||||
|
externalContractId: payload?.contractNo ? `ERP-CT-${payload.contractNo}` : null,
|
||||||
|
externalContractNo: payload?.contractNo ? `ERP-CT-${payload.contractNo}` : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'http') {
|
||||||
|
return postJsonToErp(config, {
|
||||||
|
endpoint: resolveProcurementExportEndpoint(config),
|
||||||
|
timeoutMs: coerceTimeout(config.erpProcurementTimeoutMs, config.erpSyncTimeoutMs),
|
||||||
|
payload,
|
||||||
|
successCode: 'http_synced',
|
||||||
|
successMessage: 'ERP procurement synced',
|
||||||
|
missingEndpointCode: 'erp_procurement_endpoint_missing',
|
||||||
|
missingEndpointMessage: 'ERP procurement endpoint missing',
|
||||||
|
http4xxCode: 'erp_procurement_http_4xx',
|
||||||
|
http5xxCode: 'erp_procurement_http_5xx',
|
||||||
|
timeoutCode: 'erp_procurement_timeout',
|
||||||
|
networkCode: 'erp_procurement_network_error',
|
||||||
|
messagePrefix: 'ERP procurement sync',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'erp_sync_provider_invalid',
|
||||||
|
message: `Unsupported ERP sync provider: ${provider}`,
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildErpPayload(input) {
|
function buildErpPayload(input) {
|
||||||
return buildReimbursementJournalPayload(input);
|
return buildReimbursementJournalPayload(input);
|
||||||
}
|
}
|
||||||
@@ -238,6 +300,7 @@ async function pushPaidReimbursementToErp(config, payload) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
buildErpPayload,
|
buildErpPayload,
|
||||||
exportJournalToErp,
|
exportJournalToErp,
|
||||||
|
exportProcurementToErp,
|
||||||
normalizeProvider,
|
normalizeProvider,
|
||||||
pullMasterDataFromErp,
|
pullMasterDataFromErp,
|
||||||
pushPaidReimbursementToErp,
|
pushPaidReimbursementToErp,
|
||||||
|
|||||||
@@ -0,0 +1,704 @@
|
|||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
const { enqueueIntegrationEvent } = require('./outbox');
|
||||||
|
|
||||||
|
const PROCUREMENT_TRACK_STATUSES = Object.freeze({
|
||||||
|
notTriggered: 'not_triggered',
|
||||||
|
skipped: 'skipped',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROCUREMENT_ERP_TARGETS = Object.freeze({
|
||||||
|
commitmentJournal: 'commitment_journal',
|
||||||
|
accrualJournal: 'accrual_journal',
|
||||||
|
archiveSummary: 'archive_summary',
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeString(value) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoString(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMoney(value) {
|
||||||
|
if (value == null) {
|
||||||
|
return '0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || '0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value?.toString === 'function') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrencyCode(purchaseRequest) {
|
||||||
|
return normalizeString(purchaseRequest?.currency_code) ?? 'CNY';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegalEntityCode(purchaseRequest) {
|
||||||
|
return normalizeString(purchaseRequest?.legal_entity?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCostCenterCode(purchaseRequest) {
|
||||||
|
return normalizeString(purchaseRequest?.cost_center?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSupplierCode(purchaseRequest) {
|
||||||
|
return normalizeString(purchaseRequest?.supplier?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBudgetPoolCode(purchaseRequest) {
|
||||||
|
return normalizeString(purchaseRequest?.budget_pool?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDocumentCategoryCode(purchaseRequest) {
|
||||||
|
return normalizeString(purchaseRequest?.document_category?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableNormalizeJson(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => stableNormalizeJson(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && value.constructor === Object) {
|
||||||
|
return Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = stableNormalizeJson(value[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableStringifyJson(value) {
|
||||||
|
return JSON.stringify(stableNormalizeJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashProcurementPayload(value) {
|
||||||
|
return crypto.createHash('sha256').update(stableStringifyJson(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseCommitmentJournalPayload(input) {
|
||||||
|
const purchaseRequest = input?.purchaseRequest ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
journalType: 'purchase_request_commitment',
|
||||||
|
sourceEventName: 'purchase_request.approved',
|
||||||
|
entityType: 'purchase_request',
|
||||||
|
entityId: purchaseRequest.id ?? null,
|
||||||
|
documentNo: purchaseRequest.request_no ?? null,
|
||||||
|
currencyCode: resolveCurrencyCode(purchaseRequest),
|
||||||
|
totalAmount: normalizeMoney(purchaseRequest.total_amount),
|
||||||
|
occurredAt:
|
||||||
|
toIsoString(input?.occurredAt) ??
|
||||||
|
toIsoString(purchaseRequest.updated_at) ??
|
||||||
|
toIsoString(purchaseRequest.created_at),
|
||||||
|
dimensions: {
|
||||||
|
departmentId: purchaseRequest.department_id ?? null,
|
||||||
|
applicantEmployeeId: purchaseRequest.applicant_id ?? null,
|
||||||
|
submitterEmployeeId: purchaseRequest.submitter_id ?? null,
|
||||||
|
legalEntityCode: resolveLegalEntityCode(purchaseRequest),
|
||||||
|
costCenterCode: resolveCostCenterCode(purchaseRequest),
|
||||||
|
supplierCode: resolveSupplierCode(purchaseRequest),
|
||||||
|
budgetPoolCode: resolveBudgetPoolCode(purchaseRequest),
|
||||||
|
},
|
||||||
|
references: {
|
||||||
|
purchaseRequestId: purchaseRequest.id ?? null,
|
||||||
|
documentCategoryCode: resolveDocumentCategoryCode(purchaseRequest),
|
||||||
|
requestNo: purchaseRequest.request_no ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseAccrualJournalPayload(input) {
|
||||||
|
const purchaseRequest = input?.purchaseRequest ?? {};
|
||||||
|
const archive = input?.archive ?? purchaseRequest.order_archive ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
journalType: 'purchase_order_accrual',
|
||||||
|
sourceEventName: 'purchase_order.archived',
|
||||||
|
entityType: 'purchase_order_archive',
|
||||||
|
entityId: archive.id ?? null,
|
||||||
|
documentNo: archive.po_no ?? null,
|
||||||
|
currencyCode: resolveCurrencyCode(purchaseRequest),
|
||||||
|
totalAmount: normalizeMoney(purchaseRequest.total_amount),
|
||||||
|
occurredAt:
|
||||||
|
toIsoString(input?.occurredAt) ??
|
||||||
|
toIsoString(archive.archived_at) ??
|
||||||
|
toIsoString(purchaseRequest.updated_at) ??
|
||||||
|
toIsoString(purchaseRequest.created_at),
|
||||||
|
dimensions: {
|
||||||
|
departmentId: purchaseRequest.department_id ?? null,
|
||||||
|
applicantEmployeeId: purchaseRequest.applicant_id ?? null,
|
||||||
|
submitterEmployeeId: purchaseRequest.submitter_id ?? null,
|
||||||
|
legalEntityCode: resolveLegalEntityCode(purchaseRequest),
|
||||||
|
costCenterCode: resolveCostCenterCode(purchaseRequest),
|
||||||
|
supplierCode: resolveSupplierCode(purchaseRequest),
|
||||||
|
budgetPoolCode: resolveBudgetPoolCode(purchaseRequest),
|
||||||
|
},
|
||||||
|
references: {
|
||||||
|
purchaseRequestId: purchaseRequest.id ?? null,
|
||||||
|
purchaseOrderArchiveId: archive.id ?? null,
|
||||||
|
requestNo: purchaseRequest.request_no ?? null,
|
||||||
|
poNo: archive.po_no ?? null,
|
||||||
|
contractNo: archive.contract_no ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseArchiveSummaryPayload(input) {
|
||||||
|
const purchaseRequest = input?.purchaseRequest ?? {};
|
||||||
|
const archive = input?.archive ?? purchaseRequest.order_archive ?? {};
|
||||||
|
const archivedFiles = Array.isArray(archive.archived_files)
|
||||||
|
? archive.archived_files.map((file) => ({
|
||||||
|
storagePath: file?.storagePath ?? null,
|
||||||
|
originalName: file?.originalName ?? null,
|
||||||
|
mimeType: file?.mimeType ?? null,
|
||||||
|
fileSize: Number(file?.fileSize ?? 0),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportType: 'purchase_order_archive',
|
||||||
|
sourceEventName: 'purchase_order.archived',
|
||||||
|
entityType: 'purchase_order_archive',
|
||||||
|
entityId: archive.id ?? null,
|
||||||
|
purchaseRequestId: purchaseRequest.id ?? null,
|
||||||
|
requestNo: purchaseRequest.request_no ?? null,
|
||||||
|
poNo: archive.po_no ?? null,
|
||||||
|
contractNo: archive.contract_no ?? null,
|
||||||
|
currencyCode: resolveCurrencyCode(purchaseRequest),
|
||||||
|
totalAmount: normalizeMoney(purchaseRequest.total_amount),
|
||||||
|
archivedAt:
|
||||||
|
toIsoString(input?.archivedAt) ??
|
||||||
|
toIsoString(archive.archived_at) ??
|
||||||
|
toIsoString(purchaseRequest.updated_at) ??
|
||||||
|
toIsoString(purchaseRequest.created_at),
|
||||||
|
supplier: purchaseRequest.supplier
|
||||||
|
? {
|
||||||
|
id: purchaseRequest.supplier.id,
|
||||||
|
code: purchaseRequest.supplier.code ?? null,
|
||||||
|
name: purchaseRequest.supplier.name ?? null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: purchaseRequest.supplier_id ?? null,
|
||||||
|
code: null,
|
||||||
|
name: null,
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
legalEntityCode: resolveLegalEntityCode(purchaseRequest),
|
||||||
|
costCenterCode: resolveCostCenterCode(purchaseRequest),
|
||||||
|
departmentId: purchaseRequest.department_id ?? null,
|
||||||
|
},
|
||||||
|
files: archivedFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseCommitmentJournalDedupeKey(purchaseRequestId) {
|
||||||
|
return `purchase_request:${purchaseRequestId}:commitment`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseAccrualJournalDedupeKey(archiveId) {
|
||||||
|
return `purchase_order_archive:${archiveId}:accrual`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseArchiveSummaryDedupeKey(archiveId) {
|
||||||
|
return `purchase_order_archive:${archiveId}:summary`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOutboxByKey(prisma, eventType, dedupeKey) {
|
||||||
|
if (!prisma || !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) {
|
||||||
|
if (!prisma || !entityType || !localId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.integrationBinding.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_entity_type_local_id: {
|
||||||
|
provider: 'erp',
|
||||||
|
entity_type: entityType,
|
||||||
|
local_id: localId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueuePurchaseCommitmentJournalExport(prisma, config, input) {
|
||||||
|
const payload =
|
||||||
|
input?.payload ??
|
||||||
|
buildPurchaseCommitmentJournalPayload({
|
||||||
|
purchaseRequest: input?.purchaseRequest,
|
||||||
|
occurredAt: input?.occurredAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enqueued = await enqueueIntegrationEvent(prisma, {
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
payload,
|
||||||
|
dedupeKey: buildPurchaseCommitmentJournalDedupeKey(payload.entityId),
|
||||||
|
maxAttempts: config.integrationOutboxMaxAttempts,
|
||||||
|
actorId: input?.actorId ?? null,
|
||||||
|
requeueOnFailure: input?.requeueOnFailure === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enqueued,
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
erpTarget: PROCUREMENT_ERP_TARGETS.commitmentJournal,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueuePurchaseAccrualJournalExport(prisma, config, input) {
|
||||||
|
const payload =
|
||||||
|
input?.payload ??
|
||||||
|
buildPurchaseAccrualJournalPayload({
|
||||||
|
purchaseRequest: input?.purchaseRequest,
|
||||||
|
archive: input?.archive,
|
||||||
|
occurredAt: input?.occurredAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enqueued = await enqueueIntegrationEvent(prisma, {
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
payload,
|
||||||
|
dedupeKey: buildPurchaseAccrualJournalDedupeKey(payload.entityId),
|
||||||
|
maxAttempts: config.integrationOutboxMaxAttempts,
|
||||||
|
actorId: input?.actorId ?? null,
|
||||||
|
requeueOnFailure: input?.requeueOnFailure === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enqueued,
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
erpTarget: PROCUREMENT_ERP_TARGETS.accrualJournal,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueuePurchaseArchiveSummaryExport(prisma, config, input) {
|
||||||
|
const payload =
|
||||||
|
input?.payload ??
|
||||||
|
buildPurchaseArchiveSummaryPayload({
|
||||||
|
purchaseRequest: input?.purchaseRequest,
|
||||||
|
archive: input?.archive,
|
||||||
|
archivedAt: input?.archivedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enqueued = await enqueueIntegrationEvent(prisma, {
|
||||||
|
eventType: 'erp.procurement.export',
|
||||||
|
payload,
|
||||||
|
dedupeKey: buildPurchaseArchiveSummaryDedupeKey(payload.entityId),
|
||||||
|
maxAttempts: config.integrationOutboxMaxAttempts,
|
||||||
|
actorId: input?.actorId ?? null,
|
||||||
|
requeueOnFailure: input?.requeueOnFailure === true,
|
||||||
|
resetOnPayloadChange: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enqueued,
|
||||||
|
eventType: 'erp.procurement.export',
|
||||||
|
erpTarget: PROCUREMENT_ERP_TARGETS.archiveSummary,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPurchaseRequestErpContext(prisma, purchaseRequestId) {
|
||||||
|
const purchaseRequest = await prisma.purchaseRequest.findUnique({
|
||||||
|
where: { id: purchaseRequestId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
request_no: true,
|
||||||
|
applicant_id: true,
|
||||||
|
submitter_id: true,
|
||||||
|
department_id: true,
|
||||||
|
legal_entity_id: true,
|
||||||
|
cost_center_id: true,
|
||||||
|
supplier_id: true,
|
||||||
|
document_category_id: true,
|
||||||
|
budget_pool_id: true,
|
||||||
|
status: true,
|
||||||
|
currency_code: true,
|
||||||
|
total_amount: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
applicant: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submitter: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legal_entity: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cost_center: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
document_category: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
budget_pool: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order_archive: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
po_no: true,
|
||||||
|
contract_no: true,
|
||||||
|
archived_files: true,
|
||||||
|
archived_at: true,
|
||||||
|
archived_by: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflow_instances: {
|
||||||
|
where: { entity_type: 'purchase_request' },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!purchaseRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowInstanceId = purchaseRequest.workflow_instances?.[0]?.id ?? null;
|
||||||
|
const approvalHistory = workflowInstanceId
|
||||||
|
? await prisma.workflowHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
instance_id: workflowInstanceId,
|
||||||
|
action: 'approve',
|
||||||
|
},
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
select: { created_at: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchaseRequest,
|
||||||
|
approvalOccurredAt:
|
||||||
|
approvalHistory?.created_at ?? purchaseRequest.updated_at ?? purchaseRequest.created_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyBinding(binding) {
|
||||||
|
if (!binding) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(binding.externalId || binding.externalCode || binding.lastSyncedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTrackStageStatus({ eligible, outbox, binding, bindings, gateEnabled }) {
|
||||||
|
if (outbox?.status) {
|
||||||
|
return outbox.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding && hasAnyBinding(binding)) {
|
||||||
|
return 'succeeded';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindings && Object.values(bindings).some((item) => hasAnyBinding(item))) {
|
||||||
|
return 'succeeded';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eligible) {
|
||||||
|
return PROCUREMENT_TRACK_STATUSES.notTriggered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gateEnabled) {
|
||||||
|
return PROCUREMENT_TRACK_STATUSES.skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PROCUREMENT_TRACK_STATUSES.notTriggered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseTrackSummary(context) {
|
||||||
|
const purchaseRequest = context?.purchaseRequest ?? {};
|
||||||
|
const archive = purchaseRequest.order_archive ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: purchaseRequest.id,
|
||||||
|
requestNo: purchaseRequest.request_no ?? null,
|
||||||
|
status: purchaseRequest.status,
|
||||||
|
totalAmount: normalizeMoney(purchaseRequest.total_amount),
|
||||||
|
supplier: purchaseRequest.supplier
|
||||||
|
? {
|
||||||
|
id: purchaseRequest.supplier.id,
|
||||||
|
code: purchaseRequest.supplier.code ?? null,
|
||||||
|
name: purchaseRequest.supplier.name ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
archiveOrder: archive
|
||||||
|
? {
|
||||||
|
id: archive.id,
|
||||||
|
poNo: archive.po_no,
|
||||||
|
contractNo: archive.contract_no ?? null,
|
||||||
|
archivedAt: toIsoString(archive.archived_at),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPurchaseRequestErpTrack(prisma, config, purchaseRequestId, options = {}) {
|
||||||
|
const context = options.context ?? (await loadPurchaseRequestErpContext(prisma, purchaseRequestId));
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseRequest = context.purchaseRequest;
|
||||||
|
const archive = purchaseRequest.order_archive ?? null;
|
||||||
|
const canManage = Boolean(options.canManage);
|
||||||
|
const gateEnabled = Boolean(options.gate?.effectiveEnabled ?? options.gate?.enabled ?? true);
|
||||||
|
|
||||||
|
const [
|
||||||
|
commitmentOutboxRow,
|
||||||
|
accrualOutboxRow,
|
||||||
|
summaryOutboxRow,
|
||||||
|
commitmentBindingRow,
|
||||||
|
accrualBindingRow,
|
||||||
|
poBindingRow,
|
||||||
|
contractBindingRow,
|
||||||
|
] = await Promise.all([
|
||||||
|
findOutboxByKey(
|
||||||
|
prisma,
|
||||||
|
'erp.journal.export',
|
||||||
|
buildPurchaseCommitmentJournalDedupeKey(purchaseRequest.id),
|
||||||
|
),
|
||||||
|
archive
|
||||||
|
? findOutboxByKey(
|
||||||
|
prisma,
|
||||||
|
'erp.journal.export',
|
||||||
|
buildPurchaseAccrualJournalDedupeKey(archive.id),
|
||||||
|
)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
archive
|
||||||
|
? findOutboxByKey(
|
||||||
|
prisma,
|
||||||
|
'erp.procurement.export',
|
||||||
|
buildPurchaseArchiveSummaryDedupeKey(archive.id),
|
||||||
|
)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
findBindingByLocalId(prisma, 'purchase_request_commitment_journal', purchaseRequest.id),
|
||||||
|
archive
|
||||||
|
? findBindingByLocalId(prisma, 'purchase_order_accrual_journal', archive.id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
archive ? findBindingByLocalId(prisma, 'purchase_order_po', archive.id) : Promise.resolve(null),
|
||||||
|
archive
|
||||||
|
? findBindingByLocalId(prisma, 'purchase_order_contract', archive.id)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const commitmentPreview = buildPurchaseCommitmentJournalPayload({
|
||||||
|
purchaseRequest,
|
||||||
|
occurredAt: context.approvalOccurredAt,
|
||||||
|
});
|
||||||
|
const accrualPreview = archive
|
||||||
|
? buildPurchaseAccrualJournalPayload({
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
occurredAt: archive.archived_at,
|
||||||
|
})
|
||||||
|
: {};
|
||||||
|
const summaryPreview = archive
|
||||||
|
? buildPurchaseArchiveSummaryPayload({
|
||||||
|
purchaseRequest,
|
||||||
|
archive,
|
||||||
|
archivedAt: archive.archived_at,
|
||||||
|
})
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const commitmentBinding = redactBinding(mapIntegrationBinding(commitmentBindingRow), canManage);
|
||||||
|
const accrualBinding = redactBinding(mapIntegrationBinding(accrualBindingRow), canManage);
|
||||||
|
const poBinding = redactBinding(mapIntegrationBinding(poBindingRow), canManage);
|
||||||
|
const contractBinding = redactBinding(mapIntegrationBinding(contractBindingRow), canManage);
|
||||||
|
|
||||||
|
const commitmentOutbox = mapIntegrationOutboxState(commitmentOutboxRow);
|
||||||
|
const accrualOutbox = mapIntegrationOutboxState(accrualOutboxRow);
|
||||||
|
const summaryOutbox = mapIntegrationOutboxState(summaryOutboxRow);
|
||||||
|
|
||||||
|
const commitmentEligible = purchaseRequest.status === 'approved';
|
||||||
|
const archiveEligible = Boolean(archive);
|
||||||
|
|
||||||
|
return {
|
||||||
|
purchaseRequest: buildPurchaseTrackSummary(context),
|
||||||
|
stages: {
|
||||||
|
commitmentJournal: {
|
||||||
|
eligible: commitmentEligible,
|
||||||
|
reason: commitmentEligible ? null : 'purchase_request_not_approved',
|
||||||
|
status: resolveTrackStageStatus({
|
||||||
|
eligible: commitmentEligible,
|
||||||
|
outbox: commitmentOutbox,
|
||||||
|
binding: commitmentBinding,
|
||||||
|
gateEnabled,
|
||||||
|
}),
|
||||||
|
preview: commitmentPreview,
|
||||||
|
outbox: commitmentOutbox,
|
||||||
|
binding: commitmentBinding,
|
||||||
|
},
|
||||||
|
accrualJournal: {
|
||||||
|
eligible: archiveEligible,
|
||||||
|
reason: archiveEligible ? null : 'purchase_order_archive_missing',
|
||||||
|
status: resolveTrackStageStatus({
|
||||||
|
eligible: archiveEligible,
|
||||||
|
outbox: accrualOutbox,
|
||||||
|
binding: accrualBinding,
|
||||||
|
gateEnabled,
|
||||||
|
}),
|
||||||
|
preview: accrualPreview,
|
||||||
|
outbox: accrualOutbox,
|
||||||
|
binding: accrualBinding,
|
||||||
|
},
|
||||||
|
archiveSummary: {
|
||||||
|
eligible: archiveEligible,
|
||||||
|
reason: archiveEligible ? null : 'purchase_order_archive_missing',
|
||||||
|
status: resolveTrackStageStatus({
|
||||||
|
eligible: archiveEligible,
|
||||||
|
outbox: summaryOutbox,
|
||||||
|
bindings: {
|
||||||
|
po: poBinding,
|
||||||
|
contract: contractBinding,
|
||||||
|
},
|
||||||
|
gateEnabled,
|
||||||
|
}),
|
||||||
|
preview: summaryPreview,
|
||||||
|
outbox: summaryOutbox,
|
||||||
|
bindings: {
|
||||||
|
po: poBinding,
|
||||||
|
contract: contractBinding,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
canRetry: Boolean(canManage && gateEnabled),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PROCUREMENT_ERP_TARGETS,
|
||||||
|
PROCUREMENT_TRACK_STATUSES,
|
||||||
|
buildPurchaseAccrualJournalDedupeKey,
|
||||||
|
buildPurchaseAccrualJournalPayload,
|
||||||
|
buildPurchaseArchiveSummaryDedupeKey,
|
||||||
|
buildPurchaseArchiveSummaryPayload,
|
||||||
|
buildPurchaseCommitmentJournalDedupeKey,
|
||||||
|
buildPurchaseCommitmentJournalPayload,
|
||||||
|
buildPurchaseRequestErpTrack,
|
||||||
|
enqueuePurchaseAccrualJournalExport,
|
||||||
|
enqueuePurchaseArchiveSummaryExport,
|
||||||
|
enqueuePurchaseCommitmentJournalExport,
|
||||||
|
hashProcurementPayload,
|
||||||
|
loadPurchaseRequestErpContext,
|
||||||
|
stableStringifyJson,
|
||||||
|
};
|
||||||
@@ -35,6 +35,55 @@ function formatDispatchError(error) {
|
|||||||
return parts.join(' | ');
|
return parts.join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stableNormalizeJson(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => stableNormalizeJson(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && value.constructor === Object) {
|
||||||
|
return Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = stableNormalizeJson(value[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIntegrationPayloadChanged(currentPayload, nextPayload) {
|
||||||
|
return JSON.stringify(stableNormalizeJson(currentPayload)) !== JSON.stringify(stableNormalizeJson(nextPayload));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetExistingOutbox(prisma, existed, input) {
|
||||||
|
const reset = await prisma.integrationOutbox.update({
|
||||||
|
where: { id: existed.id },
|
||||||
|
data: {
|
||||||
|
status: OUTBOX_STATUSES.pending,
|
||||||
|
payload: input.payload,
|
||||||
|
attempts: 0,
|
||||||
|
max_attempts: input.maxAttempts,
|
||||||
|
last_error: null,
|
||||||
|
next_retry_at: null,
|
||||||
|
processed_at: null,
|
||||||
|
dead_letter_at: null,
|
||||||
|
updated_by: input.actorId ?? null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: reset.id,
|
||||||
|
status: reset.status,
|
||||||
|
duplicated: false,
|
||||||
|
requeued: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function enqueueIntegrationEvent(prisma, input) {
|
async function enqueueIntegrationEvent(prisma, input) {
|
||||||
const dedupeKey = input.dedupeKey ?? null;
|
const dedupeKey = input.dedupeKey ?? null;
|
||||||
|
|
||||||
@@ -47,40 +96,23 @@ async function enqueueIntegrationEvent(prisma, input) {
|
|||||||
dedupe_key: dedupeKey,
|
dedupe_key: dedupeKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { id: true, status: true },
|
select: { id: true, status: true, payload: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (existed) {
|
if (existed) {
|
||||||
|
if (
|
||||||
|
input.resetOnPayloadChange &&
|
||||||
|
hasIntegrationPayloadChanged(existed.payload, input.payload)
|
||||||
|
) {
|
||||||
|
return resetExistingOutbox(prisma, existed, input);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
input.requeueOnFailure &&
|
input.requeueOnFailure &&
|
||||||
[OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)
|
[OUTBOX_STATUSES.failed, OUTBOX_STATUSES.dead_letter].includes(existed.status)
|
||||||
) {
|
) {
|
||||||
const reset = await prisma.integrationOutbox.update({
|
return resetExistingOutbox(prisma, existed, input);
|
||||||
where: { id: existed.id },
|
|
||||||
data: {
|
|
||||||
status: OUTBOX_STATUSES.pending,
|
|
||||||
payload: input.payload,
|
|
||||||
attempts: 0,
|
|
||||||
max_attempts: input.maxAttempts,
|
|
||||||
last_error: null,
|
|
||||||
next_retry_at: null,
|
|
||||||
processed_at: null,
|
|
||||||
dead_letter_at: null,
|
|
||||||
updated_by: input.actorId ?? null,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: reset.id,
|
|
||||||
status: reset.status,
|
|
||||||
duplicated: false,
|
|
||||||
requeued: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -188,6 +220,7 @@ module.exports = {
|
|||||||
OUTBOX_STATUSES,
|
OUTBOX_STATUSES,
|
||||||
enqueueIntegrationEvent,
|
enqueueIntegrationEvent,
|
||||||
fetchPendingOutboxEvents,
|
fetchPendingOutboxEvents,
|
||||||
|
hasIntegrationPayloadChanged,
|
||||||
markOutboxProcessing,
|
markOutboxProcessing,
|
||||||
markOutboxSucceeded,
|
markOutboxSucceeded,
|
||||||
markOutboxFailed,
|
markOutboxFailed,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const {
|
const {
|
||||||
IntegrationDispatchError,
|
IntegrationDispatchError,
|
||||||
dispatchOutboxEvent,
|
dispatchOutboxEvent,
|
||||||
} = require("../../src/services/integrations/dispatcher");
|
} = require('../../src/services/integrations/dispatcher');
|
||||||
|
|
||||||
describe("dispatchOutboxEvent", () => {
|
describe('dispatchOutboxEvent', () => {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -11,25 +11,25 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mock ERP success", async () => {
|
it('handles mock ERP success', async () => {
|
||||||
const result = await dispatchOutboxEvent({
|
const result = await dispatchOutboxEvent({
|
||||||
config: {
|
config: {
|
||||||
erpSyncEnabled: true,
|
erpSyncEnabled: true,
|
||||||
erpSyncProvider: "mock",
|
erpSyncProvider: 'mock',
|
||||||
},
|
},
|
||||||
eventType: "erp.reimbursement.paid",
|
eventType: 'erp.reimbursement.paid',
|
||||||
payload: {
|
payload: {
|
||||||
reimbursementId: "rb-1",
|
reimbursementId: 'rb-1',
|
||||||
formNumber: "BX-001",
|
formNumber: 'BX-001',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.channel).toBe("erp");
|
expect(result.channel).toBe('erp');
|
||||||
expect(result.code).toBe("mock_synced");
|
expect(result.code).toBe('mock_synced');
|
||||||
expect(result.response.externalId).toContain("BX-001");
|
expect(result.response.externalId).toContain('BX-001');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records integration binding for standard journal export", async () => {
|
it('records integration binding for reimbursement journal export', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
integrationBinding: {
|
integrationBinding: {
|
||||||
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||||
@@ -44,6 +44,7 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
},
|
},
|
||||||
eventType: 'erp.journal.export',
|
eventType: 'erp.journal.export',
|
||||||
payload: {
|
payload: {
|
||||||
|
journalType: 'reimbursement_paid',
|
||||||
entityType: 'reimbursement_form',
|
entityType: 'reimbursement_form',
|
||||||
entityId: 'rb-1',
|
entityId: 'rb-1',
|
||||||
documentNo: 'BX-20260308-0001',
|
documentNo: 'BX-20260308-0001',
|
||||||
@@ -64,23 +65,135 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads IM channel and dispatches mock wecom message", async () => {
|
it('records commitment binding for procurement journal export', async () => {
|
||||||
|
const prisma = {
|
||||||
|
integrationBinding: {
|
||||||
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchOutboxEvent({
|
||||||
|
prisma,
|
||||||
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
payload: {
|
||||||
|
journalType: 'purchase_request_commitment',
|
||||||
|
entityType: 'purchase_request',
|
||||||
|
entityId: 'pr-1',
|
||||||
|
documentNo: 'PR-001',
|
||||||
|
references: { purchaseRequestId: 'pr-1', requestNo: 'PR-001' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
provider_entity_type_local_id: {
|
||||||
|
provider: 'erp',
|
||||||
|
entity_type: 'purchase_request_commitment_journal',
|
||||||
|
local_id: 'pr-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records accrual binding for procurement journal export', async () => {
|
||||||
|
const prisma = {
|
||||||
|
integrationBinding: {
|
||||||
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchOutboxEvent({
|
||||||
|
prisma,
|
||||||
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
payload: {
|
||||||
|
journalType: 'purchase_order_accrual',
|
||||||
|
entityType: 'purchase_order_archive',
|
||||||
|
entityId: 'archive-1',
|
||||||
|
documentNo: 'PO-001',
|
||||||
|
references: { purchaseOrderArchiveId: 'archive-1', poNo: 'PO-001' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
provider_entity_type_local_id: {
|
||||||
|
provider: 'erp',
|
||||||
|
entity_type: 'purchase_order_accrual_journal',
|
||||||
|
local_id: 'archive-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records PO and contract bindings for procurement summary export', async () => {
|
||||||
|
const prisma = {
|
||||||
|
integrationBinding: {
|
||||||
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchOutboxEvent({
|
||||||
|
prisma,
|
||||||
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||||
|
eventType: 'erp.procurement.export',
|
||||||
|
payload: {
|
||||||
|
exportType: 'purchase_order_archive',
|
||||||
|
entityType: 'purchase_order_archive',
|
||||||
|
entityId: 'archive-1',
|
||||||
|
poNo: 'PO-001',
|
||||||
|
contractNo: 'CT-001',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
provider_entity_type_local_id: {
|
||||||
|
provider: 'erp',
|
||||||
|
entity_type: 'purchase_order_po',
|
||||||
|
local_id: 'archive-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
provider_entity_type_local_id: {
|
||||||
|
provider: 'erp',
|
||||||
|
entity_type: 'purchase_order_contract',
|
||||||
|
local_id: 'archive-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads IM channel and dispatches mock wecom message', async () => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "channel-1",
|
id: 'channel-1',
|
||||||
channel_type: "wecom",
|
channel_type: 'wecom',
|
||||||
name: "采购经理群",
|
name: '采购经理群',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config_json: {
|
config_json: {
|
||||||
mode: "mock",
|
mode: 'mock',
|
||||||
routing: {},
|
routing: {},
|
||||||
},
|
},
|
||||||
template_json: {
|
template_json: {
|
||||||
title: "{{title}}",
|
title: '{{title}}',
|
||||||
body: "{{body}}",
|
body: '{{body}}',
|
||||||
footer: "来自海音达工作台",
|
footer: '来自海音达工作台',
|
||||||
deepLinkLabel: "查看详情",
|
deepLinkLabel: '查看详情',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -91,18 +204,18 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
config: {
|
config: {
|
||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
},
|
},
|
||||||
eventType: "notification.im",
|
eventType: 'notification.im',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: "channel-1",
|
channelId: 'channel-1',
|
||||||
channelType: "wecom",
|
channelType: 'wecom',
|
||||||
notificationType: "workflow.task_assigned",
|
notificationType: 'workflow.task_assigned',
|
||||||
title: "【待办】采购申请待审批",
|
title: '【待办】采购申请待审批',
|
||||||
body: "采购申请已流转到你,请及时处理。",
|
body: '采购申请已流转到你,请及时处理。',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prisma.notificationChannel.findUnique).toHaveBeenCalledWith({
|
expect(prisma.notificationChannel.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "channel-1" },
|
where: { id: 'channel-1' },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
channel_type: true,
|
channel_type: true,
|
||||||
@@ -112,12 +225,12 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
template_json: true,
|
template_json: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.channel).toBe("im");
|
expect(result.channel).toBe('im');
|
||||||
expect(result.code).toBe("mock_sent");
|
expect(result.code).toBe('mock_sent');
|
||||||
expect(result.previewMessage).toContain("采购申请待审批");
|
expect(result.previewMessage).toContain('采购申请待审批');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safely skips missing IM channel", async () => {
|
it('safely skips missing IM channel', async () => {
|
||||||
const result = await dispatchOutboxEvent({
|
const result = await dispatchOutboxEvent({
|
||||||
prisma: {
|
prisma: {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
@@ -127,28 +240,28 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
config: {
|
config: {
|
||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
},
|
},
|
||||||
eventType: "notification.im",
|
eventType: 'notification.im',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: "missing-channel",
|
channelId: 'missing-channel',
|
||||||
channelType: "wecom",
|
channelType: 'wecom',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.channel).toBe("im");
|
expect(result.channel).toBe('im');
|
||||||
expect(result.code).toBe("channel_not_found");
|
expect(result.code).toBe('channel_not_found');
|
||||||
expect(result.skipped).toBe(true);
|
expect(result.skipped).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safely skips disabled IM channel", async () => {
|
it('safely skips disabled IM channel', async () => {
|
||||||
const result = await dispatchOutboxEvent({
|
const result = await dispatchOutboxEvent({
|
||||||
prisma: {
|
prisma: {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "channel-2",
|
id: 'channel-2',
|
||||||
channel_type: "wecom",
|
channel_type: 'wecom',
|
||||||
name: "disabled",
|
name: 'disabled',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
config_json: { mode: "mock", routing: {} },
|
config_json: { mode: 'mock', routing: {} },
|
||||||
template_json: null,
|
template_json: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -156,22 +269,23 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
config: {
|
config: {
|
||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
},
|
},
|
||||||
eventType: "notification.im",
|
eventType: 'notification.im',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: "channel-2",
|
channelId: 'channel-2',
|
||||||
channelType: "wecom",
|
channelType: 'wecom',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.code).toBe("channel_disabled");
|
expect(result.code).toBe('channel_disabled');
|
||||||
expect(result.skipped).toBe(true);
|
expect(result.skipped).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps wecom 4xx as non-retryable IM dispatch error", async () => {
|
it('maps wecom 4xx as non-retryable IM dispatch error', async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: "bad request" }),
|
json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: 'bad request' }),
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -179,13 +293,13 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "channel-3",
|
id: 'channel-3',
|
||||||
channel_type: "wecom",
|
channel_type: 'wecom',
|
||||||
name: "wecom-4xx",
|
name: 'wecom-4xx',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config_json: {
|
config_json: {
|
||||||
mode: "wecom_webhook",
|
mode: 'wecom_webhook',
|
||||||
webhookUrl: "https://example.com/wecom",
|
webhookUrl: 'https://example.com/wecom',
|
||||||
timeoutMs: 500,
|
timeoutMs: 500,
|
||||||
routing: {},
|
routing: {},
|
||||||
},
|
},
|
||||||
@@ -196,26 +310,27 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
config: {
|
config: {
|
||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
},
|
},
|
||||||
eventType: "notification.im",
|
eventType: 'notification.im',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: "channel-3",
|
channelId: 'channel-3',
|
||||||
channelType: "wecom",
|
channelType: 'wecom',
|
||||||
title: "test",
|
title: 'test',
|
||||||
body: "body",
|
body: 'body',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
name: "IntegrationDispatchError",
|
name: 'IntegrationDispatchError',
|
||||||
code: "wecom_http_4xx",
|
code: 'wecom_http_4xx',
|
||||||
retryable: false,
|
retryable: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps wecom 5xx as retryable IM dispatch error", async () => {
|
it('maps wecom 5xx as retryable IM dispatch error', async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 503,
|
status: 503,
|
||||||
json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: "server error" }),
|
json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: 'server error' }),
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -223,13 +338,13 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
prisma: {
|
prisma: {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "channel-4",
|
id: 'channel-4',
|
||||||
channel_type: "wecom",
|
channel_type: 'wecom',
|
||||||
name: "wecom-5xx",
|
name: 'wecom-5xx',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config_json: {
|
config_json: {
|
||||||
mode: "wecom_webhook",
|
mode: 'wecom_webhook',
|
||||||
webhookUrl: "https://example.com/wecom",
|
webhookUrl: 'https://example.com/wecom',
|
||||||
timeoutMs: 500,
|
timeoutMs: 500,
|
||||||
routing: {},
|
routing: {},
|
||||||
},
|
},
|
||||||
@@ -240,36 +355,36 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
config: {
|
config: {
|
||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
},
|
},
|
||||||
eventType: "notification.im",
|
eventType: 'notification.im',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: "channel-4",
|
channelId: 'channel-4',
|
||||||
channelType: "wecom",
|
channelType: 'wecom',
|
||||||
title: "test",
|
title: 'test',
|
||||||
body: "body",
|
body: 'body',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
name: "IntegrationDispatchError",
|
name: 'IntegrationDispatchError',
|
||||||
code: "wecom_http_5xx",
|
code: 'wecom_http_5xx',
|
||||||
retryable: true,
|
retryable: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps wecom timeout as retryable IM dispatch error", async () => {
|
it('maps wecom timeout as retryable IM dispatch error', async () => {
|
||||||
global.fetch = vi.fn().mockRejectedValue({ name: "AbortError" });
|
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
dispatchOutboxEvent({
|
dispatchOutboxEvent({
|
||||||
prisma: {
|
prisma: {
|
||||||
notificationChannel: {
|
notificationChannel: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
id: "channel-5",
|
id: 'channel-5',
|
||||||
channel_type: "wecom",
|
channel_type: 'wecom',
|
||||||
name: "wecom-timeout",
|
name: 'wecom-timeout',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config_json: {
|
config_json: {
|
||||||
mode: "wecom_webhook",
|
mode: 'wecom_webhook',
|
||||||
webhookUrl: "https://example.com/wecom",
|
webhookUrl: 'https://example.com/wecom',
|
||||||
timeoutMs: 500,
|
timeoutMs: 500,
|
||||||
routing: {},
|
routing: {},
|
||||||
},
|
},
|
||||||
@@ -280,46 +395,27 @@ describe("dispatchOutboxEvent", () => {
|
|||||||
config: {
|
config: {
|
||||||
imNotifierEnabled: true,
|
imNotifierEnabled: true,
|
||||||
},
|
},
|
||||||
eventType: "notification.im",
|
eventType: 'notification.im',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: "channel-5",
|
channelId: 'channel-5',
|
||||||
channelType: "wecom",
|
channelType: 'wecom',
|
||||||
title: "test",
|
title: 'test',
|
||||||
body: "body",
|
body: 'body',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
name: "IntegrationDispatchError",
|
name: 'IntegrationDispatchError',
|
||||||
code: "wecom_timeout",
|
code: 'wecom_timeout',
|
||||||
retryable: true,
|
retryable: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws non-retryable error for unsupported event", async () => {
|
it('throws error for unsupported event', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
dispatchOutboxEvent({
|
dispatchOutboxEvent({
|
||||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
config: {},
|
||||||
eventType: "unsupported.event",
|
eventType: 'unknown.event',
|
||||||
payload: {},
|
|
||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({
|
).rejects.toThrow('unsupported_outbox_event:unknown.event');
|
||||||
name: "IntegrationDispatchError",
|
|
||||||
code: "unsupported_outbox_event",
|
|
||||||
retryable: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves error class semantics", () => {
|
|
||||||
const error = new IntegrationDispatchError({
|
|
||||||
code: "unit_test",
|
|
||||||
message: "unit test",
|
|
||||||
retryable: true,
|
|
||||||
statusCode: 503,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
|
||||||
expect(error.code).toBe("unit_test");
|
|
||||||
expect(error.retryable).toBe(true);
|
|
||||||
expect(error.statusCode).toBe(503);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const purchaseRequestRoutes = require('../../../src/routes/purchaseRequests');
|
||||||
|
|
||||||
|
describe('purchase archive ERP helper', () => {
|
||||||
|
const { handlePurchaseArchiveErpSync } = purchaseRequestRoutes.__testables;
|
||||||
|
|
||||||
|
function createRequest() {
|
||||||
|
return {
|
||||||
|
id: 'req-1',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
headers: {},
|
||||||
|
employee: {
|
||||||
|
id: 'actor-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
permissionCodes: ['procurement.manage'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTx() {
|
||||||
|
return {
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext() {
|
||||||
|
return {
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
order_archive: {
|
||||||
|
id: 'archive-1',
|
||||||
|
po_no: 'PO-001',
|
||||||
|
contract_no: 'CT-001',
|
||||||
|
archived_at: new Date('2026-03-08T10:30:00.000Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('first archive enqueues accrual and summary', async () => {
|
||||||
|
const tx = createTx();
|
||||||
|
const loadContext = vi.fn().mockResolvedValue(createContext());
|
||||||
|
const evaluateGate = vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
reason: 'env_inherit_department_allowed',
|
||||||
|
});
|
||||||
|
const enqueueAccrual = vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-accrual',
|
||||||
|
status: 'pending',
|
||||||
|
duplicated: false,
|
||||||
|
requeued: false,
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
erpTarget: 'accrual_journal',
|
||||||
|
});
|
||||||
|
const enqueueSummary = vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-summary',
|
||||||
|
status: 'pending',
|
||||||
|
duplicated: false,
|
||||||
|
requeued: false,
|
||||||
|
eventType: 'erp.procurement.export',
|
||||||
|
erpTarget: 'archive_summary',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handlePurchaseArchiveErpSync({
|
||||||
|
tx,
|
||||||
|
config: {},
|
||||||
|
request: createRequest(),
|
||||||
|
actorId: 'actor-1',
|
||||||
|
purchaseRequestId: 'pr-1',
|
||||||
|
archiveId: 'archive-1',
|
||||||
|
isFirstArchive: true,
|
||||||
|
loadContext,
|
||||||
|
evaluateGate,
|
||||||
|
enqueueAccrual,
|
||||||
|
enqueueSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueAccrual).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
{},
|
||||||
|
expect.objectContaining({ requeueOnFailure: false }),
|
||||||
|
);
|
||||||
|
expect(enqueueSummary).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
{},
|
||||||
|
expect.objectContaining({ requeueOnFailure: false }),
|
||||||
|
);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archive update only re-enqueues summary', async () => {
|
||||||
|
const tx = createTx();
|
||||||
|
const loadContext = vi.fn().mockResolvedValue(createContext());
|
||||||
|
const evaluateGate = vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
reason: 'env_inherit_department_allowed',
|
||||||
|
});
|
||||||
|
const enqueueAccrual = vi.fn();
|
||||||
|
const enqueueSummary = vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-summary',
|
||||||
|
status: 'pending',
|
||||||
|
duplicated: false,
|
||||||
|
requeued: true,
|
||||||
|
eventType: 'erp.procurement.export',
|
||||||
|
erpTarget: 'archive_summary',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handlePurchaseArchiveErpSync({
|
||||||
|
tx,
|
||||||
|
config: {},
|
||||||
|
request: createRequest(),
|
||||||
|
actorId: 'actor-1',
|
||||||
|
purchaseRequestId: 'pr-1',
|
||||||
|
archiveId: 'archive-1',
|
||||||
|
isFirstArchive: false,
|
||||||
|
loadContext,
|
||||||
|
evaluateGate,
|
||||||
|
enqueueAccrual,
|
||||||
|
enqueueSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueAccrual).not.toHaveBeenCalled();
|
||||||
|
expect(enqueueSummary).toHaveBeenCalled();
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0].erpTarget).toBe('archive_summary');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
const workflowRoutes = require('../../../src/routes/workflow');
|
||||||
|
|
||||||
|
describe('workflow purchase ERP helper', () => {
|
||||||
|
const { handlePurchaseApprovalErpSync } = workflowRoutes.__testables;
|
||||||
|
|
||||||
|
function createRequest() {
|
||||||
|
return {
|
||||||
|
id: 'req-1',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
headers: {},
|
||||||
|
employee: {
|
||||||
|
id: 'actor-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
permissionCodes: ['procurement.manage'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTx() {
|
||||||
|
return {
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('auto enqueues commitment journal when gate is enabled', async () => {
|
||||||
|
const tx = createTx();
|
||||||
|
const loadContext = vi.fn().mockResolvedValue({
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
},
|
||||||
|
approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const evaluateGate = vi.fn().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
reason: 'env_inherit_department_allowed',
|
||||||
|
});
|
||||||
|
const enqueueCommitment = vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-1',
|
||||||
|
status: 'pending',
|
||||||
|
duplicated: false,
|
||||||
|
requeued: false,
|
||||||
|
eventType: 'erp.journal.export',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handlePurchaseApprovalErpSync({
|
||||||
|
tx,
|
||||||
|
config: {},
|
||||||
|
request: createRequest(),
|
||||||
|
actorId: 'actor-1',
|
||||||
|
purchaseRequestId: 'pr-1',
|
||||||
|
loadContext,
|
||||||
|
evaluateGate,
|
||||||
|
enqueueCommitment,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueCommitment).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
{},
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: 'actor-1',
|
||||||
|
requeueOnFailure: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'ERP_SYNC_ENQUEUED',
|
||||||
|
target_type: 'purchase_request',
|
||||||
|
target_id: 'pr-1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.skipped).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes skip audit when approval gate is disabled', async () => {
|
||||||
|
const tx = createTx();
|
||||||
|
const loadContext = vi.fn().mockResolvedValue({
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
},
|
||||||
|
approvalOccurredAt: new Date('2026-03-08T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const evaluateGate = vi.fn().mockResolvedValue({ enabled: false, reason: 'flag_force_off' });
|
||||||
|
const enqueueCommitment = vi.fn();
|
||||||
|
|
||||||
|
const result = await handlePurchaseApprovalErpSync({
|
||||||
|
tx,
|
||||||
|
config: {},
|
||||||
|
request: createRequest(),
|
||||||
|
actorId: 'actor-1',
|
||||||
|
purchaseRequestId: 'pr-1',
|
||||||
|
loadContext,
|
||||||
|
evaluateGate,
|
||||||
|
enqueueCommitment,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enqueueCommitment).not.toHaveBeenCalled();
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: 'ERP_SYNC_SKIPPED_BY_FEATURE_FLAG',
|
||||||
|
target_type: 'purchase_request',
|
||||||
|
target_id: 'pr-1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.skipped).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const {
|
||||||
|
buildPurchaseAccrualJournalPayload,
|
||||||
|
buildPurchaseArchiveSummaryPayload,
|
||||||
|
buildPurchaseCommitmentJournalPayload,
|
||||||
|
enqueuePurchaseArchiveSummaryExport,
|
||||||
|
hashProcurementPayload,
|
||||||
|
} = require('../../../src/services/integrations/erpProcurement');
|
||||||
|
|
||||||
|
describe('ERP Procurement Service', () => {
|
||||||
|
it('builds commitment payload with procurement dimensions', () => {
|
||||||
|
const payload = buildPurchaseCommitmentJournalPayload({
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
request_no: 'PR-001',
|
||||||
|
applicant_id: 'applicant-1',
|
||||||
|
submitter_id: 'submitter-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
total_amount: { toString: () => '880.00' },
|
||||||
|
currency_code: 'CNY',
|
||||||
|
legal_entity: { code: 'HY' },
|
||||||
|
cost_center: { code: 'IT001' },
|
||||||
|
supplier: { code: 'SUP001' },
|
||||||
|
budget_pool: { code: 'BG-IT-2026' },
|
||||||
|
document_category: { code: 'PR_STANDARD' },
|
||||||
|
},
|
||||||
|
occurredAt: '2026-03-08T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.journalType).toBe('purchase_request_commitment');
|
||||||
|
expect(payload.references.requestNo).toBe('PR-001');
|
||||||
|
expect(payload.dimensions.legalEntityCode).toBe('HY');
|
||||||
|
expect(payload.dimensions.costCenterCode).toBe('IT001');
|
||||||
|
expect(payload.dimensions.supplierCode).toBe('SUP001');
|
||||||
|
expect(payload.dimensions.budgetPoolCode).toBe('BG-IT-2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds accrual payload from archive facts', () => {
|
||||||
|
const payload = buildPurchaseAccrualJournalPayload({
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
request_no: 'PR-001',
|
||||||
|
applicant_id: 'applicant-1',
|
||||||
|
submitter_id: 'submitter-1',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
total_amount: '880.00',
|
||||||
|
currency_code: 'CNY',
|
||||||
|
legal_entity: { code: 'HY' },
|
||||||
|
cost_center: { code: 'IT001' },
|
||||||
|
supplier: { code: 'SUP001' },
|
||||||
|
budget_pool: { code: 'BG-IT-2026' },
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
id: 'archive-1',
|
||||||
|
po_no: 'PO-001',
|
||||||
|
contract_no: 'CT-001',
|
||||||
|
archived_at: '2026-03-08T10:30:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.journalType).toBe('purchase_order_accrual');
|
||||||
|
expect(payload.entityId).toBe('archive-1');
|
||||||
|
expect(payload.documentNo).toBe('PO-001');
|
||||||
|
expect(payload.references.contractNo).toBe('CT-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds archive summary payload and hashes payload changes', () => {
|
||||||
|
const basePayload = buildPurchaseArchiveSummaryPayload({
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
request_no: 'PR-001',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
total_amount: '880.00',
|
||||||
|
currency_code: 'CNY',
|
||||||
|
legal_entity: { code: 'HY' },
|
||||||
|
cost_center: { code: 'IT001' },
|
||||||
|
supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' },
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
id: 'archive-1',
|
||||||
|
po_no: 'PO-001',
|
||||||
|
contract_no: 'CT-001',
|
||||||
|
archived_at: '2026-03-08T10:30:00.000Z',
|
||||||
|
archived_files: [
|
||||||
|
{
|
||||||
|
storagePath: 'purchase-requests/pr-1/contract.pdf',
|
||||||
|
originalName: 'contract.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileSize: 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const changedPayload = {
|
||||||
|
...basePayload,
|
||||||
|
contractNo: 'CT-002',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(basePayload.files).toHaveLength(1);
|
||||||
|
expect(hashProcurementPayload(basePayload)).not.toBe(hashProcurementPayload(changedPayload));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets archive summary outbox when payload changes', async () => {
|
||||||
|
const prisma = {
|
||||||
|
integrationOutbox: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: 'outbox-1',
|
||||||
|
status: 'succeeded',
|
||||||
|
payload: { contractNo: 'CT-001', poNo: 'PO-001' },
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockResolvedValue({ id: 'outbox-1', status: 'pending' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await enqueuePurchaseArchiveSummaryExport(
|
||||||
|
prisma,
|
||||||
|
{ integrationOutboxMaxAttempts: 5 },
|
||||||
|
{
|
||||||
|
actorId: 'actor-1',
|
||||||
|
purchaseRequest: {
|
||||||
|
id: 'pr-1',
|
||||||
|
request_no: 'PR-001',
|
||||||
|
department_id: 'dept-1',
|
||||||
|
total_amount: '880.00',
|
||||||
|
currency_code: 'CNY',
|
||||||
|
supplier: { id: 'sup-1', code: 'SUP001', name: '演示供应商' },
|
||||||
|
legal_entity: { code: 'HY' },
|
||||||
|
cost_center: { code: 'IT001' },
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
id: 'archive-1',
|
||||||
|
po_no: 'PO-001',
|
||||||
|
contract_no: 'CT-002',
|
||||||
|
archived_at: '2026-03-08T10:30:00.000Z',
|
||||||
|
archived_files: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.integrationOutbox.update).toHaveBeenCalled();
|
||||||
|
expect(result.requeued).toBe(true);
|
||||||
|
expect(result.eventType).toBe('erp.procurement.export');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,11 +33,22 @@ describe('ERP Sync Services', () => {
|
|||||||
it('returns mock synced when journal export provider is mock', async () => {
|
it('returns mock synced when journal export provider is mock', async () => {
|
||||||
const result = await erp.exportJournalToErp(
|
const result = await erp.exportJournalToErp(
|
||||||
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||||
{ documentNo: 'BX-001' },
|
{ journalType: 'purchase_request_commitment', documentNo: 'PR-001' },
|
||||||
);
|
);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.code).toBe('mock_synced');
|
expect(result.code).toBe('mock_synced');
|
||||||
expect(result.response.externalId).toContain('BX-001');
|
expect(result.response.externalId).toContain('PR-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns mock procurement result when provider is mock', async () => {
|
||||||
|
const result = await erp.exportProcurementToErp(
|
||||||
|
{ erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
||||||
|
{ poNo: 'PO-001', contractNo: 'CT-001' },
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.code).toBe('mock_synced');
|
||||||
|
expect(result.response.externalPoNo).toContain('PO-001');
|
||||||
|
expect(result.response.externalContractNo).toContain('CT-001');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns mock master data result when provider is mock', async () => {
|
it('returns mock master data result when provider is mock', async () => {
|
||||||
@@ -74,6 +85,70 @@ describe('ERP Sync Services', () => {
|
|||||||
expect(result.retryable).toBe(false);
|
expect(result.retryable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles http journal export 5xx errors', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 'service_unavailable' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await erp.exportJournalToErp(
|
||||||
|
{
|
||||||
|
erpSyncEnabled: true,
|
||||||
|
erpSyncProvider: 'http',
|
||||||
|
erpJournalExportEndpoint: 'https://example.com/journals',
|
||||||
|
erpSyncTimeoutMs: 500,
|
||||||
|
},
|
||||||
|
{ journalType: 'purchase_order_accrual', documentNo: 'PO-001' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.code).toBe('erp_sync_http_5xx');
|
||||||
|
expect(result.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles http procurement export 4xx errors', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
json: vi.fn().mockResolvedValue({ code: 'bad_request' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await erp.exportProcurementToErp(
|
||||||
|
{
|
||||||
|
erpSyncEnabled: true,
|
||||||
|
erpSyncProvider: 'http',
|
||||||
|
erpProcurementExportEndpoint: 'https://example.com/procurement',
|
||||||
|
erpProcurementTimeoutMs: 500,
|
||||||
|
},
|
||||||
|
{ exportType: 'purchase_order_archive', poNo: 'PO-001' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.code).toBe('erp_procurement_http_4xx');
|
||||||
|
expect(result.retryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles http procurement export timeout', async () => {
|
||||||
|
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
|
||||||
|
|
||||||
|
const result = await erp.exportProcurementToErp(
|
||||||
|
{
|
||||||
|
erpSyncEnabled: true,
|
||||||
|
erpSyncProvider: 'http',
|
||||||
|
erpProcurementExportEndpoint: 'https://example.com/procurement',
|
||||||
|
erpProcurementTimeoutMs: 500,
|
||||||
|
},
|
||||||
|
{ exportType: 'purchase_order_archive', poNo: 'PO-001' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.code).toBe('erp_procurement_timeout');
|
||||||
|
expect(result.retryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('handles http journal export timeout', async () => {
|
it('handles http journal export timeout', async () => {
|
||||||
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
|
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户