feat(P19ERP1-1,P19ERP1-2): 新增ERP主数据同步模型与服务
这个提交包含在:
@@ -1,24 +1,11 @@
|
||||
function buildErpPayload(input) {
|
||||
return {
|
||||
reimbursementId: input.reimbursement.id,
|
||||
formNumber: input.reimbursement.form_number,
|
||||
ownerEmployeeId: input.reimbursement.owner_id,
|
||||
submitterEmployeeId: input.reimbursement.submitter_id,
|
||||
totalAmount: input.reimbursement.total_amount?.toString?.() ?? null,
|
||||
paymentVoucherNo: input.reimbursement.payment_voucher_no ?? null,
|
||||
paymentDate: input.reimbursement.payment_date
|
||||
? new Date(input.reimbursement.payment_date).toISOString().slice(0, 10)
|
||||
: null,
|
||||
paidAt: input.reimbursement.paid_at ? new Date(input.reimbursement.paid_at).toISOString() : null,
|
||||
};
|
||||
}
|
||||
const { buildReimbursementJournalPayload } = require('./erpJournal');
|
||||
|
||||
function normalizeProvider(config) {
|
||||
return String(config.erpSyncProvider ?? "mock").trim().toLowerCase() || "mock";
|
||||
return String(config.erpSyncProvider ?? 'mock').trim().toLowerCase() || 'mock';
|
||||
}
|
||||
|
||||
function buildAuthHeaders(config) {
|
||||
const token = String(config.erpSyncAuthToken ?? "").trim();
|
||||
const token = String(config.erpSyncAuthToken ?? '').trim();
|
||||
if (!token) {
|
||||
return {};
|
||||
}
|
||||
@@ -28,67 +15,87 @@ function buildAuthHeaders(config) {
|
||||
};
|
||||
}
|
||||
|
||||
async function postToErpHttp(config, payload) {
|
||||
const endpoint = String(config.erpSyncEndpoint ?? "").trim();
|
||||
function coerceTimeout(value, fallback) {
|
||||
const parsed = Number(value ?? fallback ?? 5000);
|
||||
if (Number.isFinite(parsed) && parsed >= 100) {
|
||||
return parsed;
|
||||
}
|
||||
return Math.max(100, Number(fallback ?? 5000) || 5000);
|
||||
}
|
||||
|
||||
function resolveMasterDataEndpoint(config) {
|
||||
return String(config.erpMasterDataEndpoint ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveJournalExportEndpoint(config) {
|
||||
return (
|
||||
String(config.erpJournalExportEndpoint ?? '').trim() ||
|
||||
String(config.erpSyncEndpoint ?? '').trim()
|
||||
);
|
||||
}
|
||||
|
||||
async function parseResponseBody(response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function postJsonToErp(config, options) {
|
||||
const endpoint = String(options.endpoint ?? '').trim();
|
||||
if (!endpoint) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "erp_sync_endpoint_missing",
|
||||
message: "ERP sync endpoint missing",
|
||||
code: options.missingEndpointCode,
|
||||
message: options.missingEndpointMessage,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(100, Number(config.erpSyncTimeoutMs ?? 5000));
|
||||
const timeoutMs = coerceTimeout(options.timeoutMs, config.erpSyncTimeoutMs);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
'content-type': 'application/json',
|
||||
...buildAuthHeaders(config),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(options.payload ?? {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let responseBody = null;
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch {
|
||||
responseBody = null;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
responseBody = await response.text();
|
||||
} catch {
|
||||
responseBody = null;
|
||||
}
|
||||
}
|
||||
|
||||
const responseBody = await parseResponseBody(response);
|
||||
if (response.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
code: "http_synced",
|
||||
message: "ERP synced",
|
||||
code: options.successCode,
|
||||
message: options.successMessage,
|
||||
response: {
|
||||
status: response.status,
|
||||
body: responseBody,
|
||||
},
|
||||
data: responseBody,
|
||||
};
|
||||
}
|
||||
|
||||
const retryable = response.status >= 500;
|
||||
return {
|
||||
ok: false,
|
||||
code: retryable ? "erp_sync_http_5xx" : "erp_sync_http_4xx",
|
||||
message: `ERP sync HTTP ${response.status}`,
|
||||
code: retryable ? options.http5xxCode : options.http4xxCode,
|
||||
message: `${options.messagePrefix} HTTP ${response.status}`,
|
||||
retryable,
|
||||
httpStatus: response.status,
|
||||
response: {
|
||||
@@ -97,19 +104,19 @@ async function postToErpHttp(config, payload) {
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
if (error?.name === 'AbortError') {
|
||||
return {
|
||||
ok: false,
|
||||
code: "erp_sync_timeout",
|
||||
message: `ERP sync timeout (${timeoutMs}ms)`,
|
||||
code: options.timeoutCode,
|
||||
message: `${options.messagePrefix} timeout (${timeoutMs}ms)`,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: "erp_sync_network_error",
|
||||
message: String(error?.message ?? error ?? "ERP sync network error"),
|
||||
code: options.networkCode,
|
||||
message: String(error?.message ?? error ?? `${options.messagePrefix} network error`),
|
||||
retryable: true,
|
||||
};
|
||||
} finally {
|
||||
@@ -117,41 +124,121 @@ async function postToErpHttp(config, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushPaidReimbursementToErp(config, payload) {
|
||||
async function pullMasterDataFromErp(config, options = {}) {
|
||||
if (!config.erpSyncEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "erp_sync_disabled",
|
||||
message: "ERP sync disabled",
|
||||
code: 'erp_sync_disabled',
|
||||
message: 'ERP sync disabled',
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = normalizeProvider(config);
|
||||
if (provider === "mock") {
|
||||
const resources = Array.isArray(options.resources) ? options.resources : [];
|
||||
|
||||
if (provider === 'mock') {
|
||||
return {
|
||||
ok: true,
|
||||
code: "mock_synced",
|
||||
message: "mock ERP synced",
|
||||
code: 'mock_synced',
|
||||
message: 'mock ERP master data pulled',
|
||||
response: {
|
||||
externalId: `ERP-${payload.formNumber ?? payload.reimbursementId}`,
|
||||
provider,
|
||||
},
|
||||
data: {
|
||||
ok: true,
|
||||
legalEntities: [],
|
||||
costCenters: [],
|
||||
suppliers: [],
|
||||
requestedResources: resources,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === "http") {
|
||||
return postToErpHttp(config, payload);
|
||||
if (provider === 'http') {
|
||||
return postJsonToErp(config, {
|
||||
endpoint: resolveMasterDataEndpoint(config),
|
||||
timeoutMs: coerceTimeout(config.erpMasterDataTimeoutMs, config.erpSyncTimeoutMs),
|
||||
payload: { resources },
|
||||
successCode: 'http_synced',
|
||||
successMessage: 'ERP master data pulled',
|
||||
missingEndpointCode: 'erp_master_data_endpoint_missing',
|
||||
missingEndpointMessage: 'ERP master data endpoint missing',
|
||||
http4xxCode: 'erp_master_data_http_4xx',
|
||||
http5xxCode: 'erp_master_data_http_5xx',
|
||||
timeoutCode: 'erp_master_data_timeout',
|
||||
networkCode: 'erp_master_data_network_error',
|
||||
messagePrefix: 'ERP master data pull',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: "erp_sync_provider_invalid",
|
||||
code: 'erp_master_data_provider_invalid',
|
||||
message: `Unsupported ERP master data provider: ${provider}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function exportJournalToErp(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') {
|
||||
return {
|
||||
ok: true,
|
||||
code: 'mock_synced',
|
||||
message: 'mock ERP synced',
|
||||
response: {
|
||||
externalId: `ERP-${payload?.documentNo ?? payload?.formNumber ?? payload?.entityId ?? payload?.reimbursementId ?? 'UNKNOWN'}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'http') {
|
||||
return postJsonToErp(config, {
|
||||
endpoint: resolveJournalExportEndpoint(config),
|
||||
timeoutMs: coerceTimeout(config.erpSyncTimeoutMs, config.erpSyncTimeoutMs),
|
||||
payload,
|
||||
successCode: 'http_synced',
|
||||
successMessage: 'ERP synced',
|
||||
missingEndpointCode: 'erp_sync_endpoint_missing',
|
||||
missingEndpointMessage: 'ERP sync endpoint missing',
|
||||
http4xxCode: 'erp_sync_http_4xx',
|
||||
http5xxCode: 'erp_sync_http_5xx',
|
||||
timeoutCode: 'erp_sync_timeout',
|
||||
networkCode: 'erp_sync_network_error',
|
||||
messagePrefix: 'ERP sync',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: 'erp_sync_provider_invalid',
|
||||
message: `Unsupported ERP sync provider: ${provider}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildErpPayload(input) {
|
||||
return buildReimbursementJournalPayload(input);
|
||||
}
|
||||
|
||||
async function pushPaidReimbursementToErp(config, payload) {
|
||||
return exportJournalToErp(config, payload);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildErpPayload,
|
||||
exportJournalToErp,
|
||||
normalizeProvider,
|
||||
pullMasterDataFromErp,
|
||||
pushPaidReimbursementToErp,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户