feat(P19ERP1-1,P19ERP1-2): 新增ERP主数据同步模型与服务
这个提交包含在:
@@ -0,0 +1,534 @@
|
||||
const { pullMasterDataFromErp } = require('./erp');
|
||||
|
||||
const ERP_MASTER_DATA_PROVIDER = 'erp';
|
||||
const ERP_MASTER_DATA_RESOURCES = Object.freeze(['legal_entity', 'cost_center', 'supplier']);
|
||||
|
||||
function normalizeRequestedResources(resources) {
|
||||
if (!Array.isArray(resources) || !resources.length) {
|
||||
return [...ERP_MASTER_DATA_RESOURCES];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const resource of ERP_MASTER_DATA_RESOURCES) {
|
||||
if (resources.includes(resource) && !seen.has(resource)) {
|
||||
seen.add(resource);
|
||||
result.push(resource);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeSummary(requested) {
|
||||
return {
|
||||
requested,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
archived: 0,
|
||||
skipped: 0,
|
||||
sampleSkipped: [],
|
||||
};
|
||||
}
|
||||
|
||||
function addSkip(summary, key, reason) {
|
||||
summary.skipped += 1;
|
||||
if (summary.sampleSkipped.length < 20) {
|
||||
summary.sampleSkipped.push({ key: key ?? 'unknown', reason });
|
||||
}
|
||||
}
|
||||
|
||||
function mapSyncState(record) {
|
||||
return {
|
||||
resourceType: record.resource_type,
|
||||
status: record.status,
|
||||
lastStartedAt: record.last_started_at?.toISOString?.() ?? null,
|
||||
lastFinishedAt: record.last_finished_at?.toISOString?.() ?? null,
|
||||
lastSuccessAt: record.last_success_at?.toISOString?.() ?? null,
|
||||
lastError: record.last_error ?? null,
|
||||
lastResult: record.last_result ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertSyncState(tx, resourceType, data) {
|
||||
return tx.integrationSyncState.upsert({
|
||||
where: {
|
||||
provider_resource_type: {
|
||||
provider: ERP_MASTER_DATA_PROVIDER,
|
||||
resource_type: resourceType,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
provider: ERP_MASTER_DATA_PROVIDER,
|
||||
resource_type: resourceType,
|
||||
...data,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
|
||||
async function markSyncStateRunning(tx, resourceType, actorId) {
|
||||
return upsertSyncState(tx, resourceType, {
|
||||
status: 'running',
|
||||
last_started_at: new Date(),
|
||||
last_finished_at: null,
|
||||
last_error: null,
|
||||
updated_by: actorId ?? null,
|
||||
created_by: actorId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function markSyncStateSucceeded(tx, resourceType, actorId, result) {
|
||||
const now = new Date();
|
||||
return upsertSyncState(tx, resourceType, {
|
||||
status: 'succeeded',
|
||||
last_result: result,
|
||||
last_error: null,
|
||||
last_finished_at: now,
|
||||
last_success_at: now,
|
||||
updated_by: actorId ?? null,
|
||||
created_by: actorId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function markSyncStateFailed(tx, resourceType, actorId, error) {
|
||||
return upsertSyncState(tx, resourceType, {
|
||||
status: 'failed',
|
||||
last_error: String(error?.message ?? error ?? 'erp_master_data_sync_failed').slice(0, 500),
|
||||
last_finished_at: new Date(),
|
||||
updated_by: actorId ?? null,
|
||||
created_by: actorId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function listMasterDataSyncStates(prisma) {
|
||||
const rows = await prisma.integrationSyncState.findMany({
|
||||
where: {
|
||||
provider: ERP_MASTER_DATA_PROVIDER,
|
||||
resource_type: { in: ERP_MASTER_DATA_RESOURCES },
|
||||
},
|
||||
orderBy: [{ resource_type: 'asc' }],
|
||||
});
|
||||
|
||||
const map = new Map(rows.map((row) => [row.resource_type, row]));
|
||||
return ERP_MASTER_DATA_RESOURCES.map((resourceType) => {
|
||||
const row = map.get(resourceType);
|
||||
if (!row) {
|
||||
return {
|
||||
resourceType,
|
||||
status: 'idle',
|
||||
lastStartedAt: null,
|
||||
lastFinishedAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
lastResult: null,
|
||||
};
|
||||
}
|
||||
return mapSyncState(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBindingNotConflicted(tx, entityType, localId, externalId) {
|
||||
if (!externalId) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const binding = await tx.integrationBinding.findFirst({
|
||||
where: {
|
||||
provider: ERP_MASTER_DATA_PROVIDER,
|
||||
entity_type: entityType,
|
||||
external_id: externalId,
|
||||
},
|
||||
select: { local_id: true },
|
||||
});
|
||||
|
||||
if (binding && binding.local_id !== localId) {
|
||||
return { ok: false, reason: 'binding_conflict' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function upsertBinding(tx, entityType, localId, externalId, externalCode, actorId) {
|
||||
return tx.integrationBinding.upsert({
|
||||
where: {
|
||||
provider_entity_type_local_id: {
|
||||
provider: ERP_MASTER_DATA_PROVIDER,
|
||||
entity_type: entityType,
|
||||
local_id: localId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
provider: ERP_MASTER_DATA_PROVIDER,
|
||||
entity_type: entityType,
|
||||
local_id: localId,
|
||||
external_id: externalId ?? null,
|
||||
external_code: externalCode ?? null,
|
||||
last_synced_at: new Date(),
|
||||
created_by: actorId ?? null,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
update: {
|
||||
external_id: externalId ?? null,
|
||||
external_code: externalCode ?? null,
|
||||
last_synced_at: new Date(),
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePayloadRows(rows, resolveKey) {
|
||||
const summary = makeSummary(Array.isArray(rows) ? rows.length : 0);
|
||||
const uniqueRows = [];
|
||||
const seen = new Set();
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
const key = resolveKey(row);
|
||||
if (!key) {
|
||||
addSkip(summary, null, 'external_code_missing');
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
addSkip(summary, key, 'duplicate_payload_key');
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
uniqueRows.push(row);
|
||||
}
|
||||
return { summary, rows: uniqueRows };
|
||||
}
|
||||
|
||||
function resolveArchivedState(enabled, archivedAt) {
|
||||
if (enabled === false) {
|
||||
return archivedAt ?? new Date();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function syncLegalEntities(tx, rows, actorId) {
|
||||
const prepared = normalizePayloadRows(rows, (row) => String(row?.code ?? '').trim() || null);
|
||||
const summary = prepared.summary;
|
||||
const codes = prepared.rows.map((row) => String(row.code).trim());
|
||||
const existingRows = await tx.legalEntity.findMany({
|
||||
where: { code: { in: codes } },
|
||||
select: { id: true, code: true, enabled: true, archived_at: true },
|
||||
});
|
||||
const existingByCode = new Map(existingRows.map((row) => [row.code, row]));
|
||||
|
||||
for (const row of prepared.rows) {
|
||||
const code = String(row.code).trim();
|
||||
const existing = existingByCode.get(code);
|
||||
const enabled = row.enabled !== false;
|
||||
const archivedAt = resolveArchivedState(enabled, existing?.archived_at ?? null);
|
||||
|
||||
if (existing) {
|
||||
const bindingCheck = await ensureBindingNotConflicted(tx, 'legal_entity', existing.id, row.externalId ?? null);
|
||||
if (!bindingCheck.ok) {
|
||||
addSkip(summary, code, bindingCheck.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.legalEntity.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: row.name,
|
||||
short_name: row.shortName ?? null,
|
||||
currency_code: row.currencyCode ?? 'CNY',
|
||||
tax_no: row.taxNo ?? null,
|
||||
enabled,
|
||||
archived_at: archivedAt,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
});
|
||||
summary.updated += 1;
|
||||
if (!enabled && (existing.enabled || !existing.archived_at)) {
|
||||
summary.archived += 1;
|
||||
}
|
||||
await upsertBinding(tx, 'legal_entity', existing.id, row.externalId ?? null, code, actorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await tx.legalEntity.create({
|
||||
data: {
|
||||
code,
|
||||
name: row.name,
|
||||
short_name: row.shortName ?? null,
|
||||
currency_code: row.currencyCode ?? 'CNY',
|
||||
tax_no: row.taxNo ?? null,
|
||||
enabled,
|
||||
archived_at: enabled ? null : new Date(),
|
||||
created_by: actorId ?? null,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
summary.created += 1;
|
||||
await upsertBinding(tx, 'legal_entity', created.id, row.externalId ?? null, code, actorId);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function syncCostCenters(tx, rows, actorId) {
|
||||
const prepared = normalizePayloadRows(
|
||||
rows,
|
||||
(row) => {
|
||||
const legalEntityCode = String(row?.legalEntityCode ?? '').trim();
|
||||
const code = String(row?.code ?? '').trim();
|
||||
return legalEntityCode && code ? `${legalEntityCode}::${code}` : null;
|
||||
},
|
||||
);
|
||||
const summary = prepared.summary;
|
||||
const legalEntityCodes = Array.from(new Set(prepared.rows.map((row) => String(row.legalEntityCode).trim())));
|
||||
const legalEntities = await tx.legalEntity.findMany({
|
||||
where: { code: { in: legalEntityCodes } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
const legalEntityByCode = new Map(legalEntities.map((row) => [row.code, row]));
|
||||
|
||||
const existingRows = await tx.costCenter.findMany({
|
||||
where: {
|
||||
OR: prepared.rows.map((row) => ({
|
||||
code: String(row.code).trim(),
|
||||
legal_entity: { code: String(row.legalEntityCode).trim() },
|
||||
})),
|
||||
},
|
||||
select: { id: true, code: true, legal_entity_id: true, enabled: true, archived_at: true, legal_entity: { select: { code: true } } },
|
||||
}).catch(() => []);
|
||||
const existingByKey = new Map(existingRows.map((row) => [`${row.legal_entity?.code ?? ''}::${row.code}`, row]));
|
||||
|
||||
for (const row of prepared.rows) {
|
||||
const legalEntityCode = String(row.legalEntityCode).trim();
|
||||
const code = String(row.code).trim();
|
||||
const key = `${legalEntityCode}::${code}`;
|
||||
const legalEntity = legalEntityByCode.get(legalEntityCode);
|
||||
if (!legalEntity) {
|
||||
addSkip(summary, key, 'legal_entity_missing');
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = existingByKey.get(key);
|
||||
const enabled = row.enabled !== false;
|
||||
const archivedAt = resolveArchivedState(enabled, existing?.archived_at ?? null);
|
||||
|
||||
if (existing) {
|
||||
const bindingCheck = await ensureBindingNotConflicted(tx, 'cost_center', existing.id, row.externalId ?? null);
|
||||
if (!bindingCheck.ok) {
|
||||
addSkip(summary, key, bindingCheck.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.costCenter.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: row.name,
|
||||
enabled,
|
||||
archived_at: archivedAt,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
});
|
||||
summary.updated += 1;
|
||||
if (!enabled && (existing.enabled || !existing.archived_at)) {
|
||||
summary.archived += 1;
|
||||
}
|
||||
await upsertBinding(tx, 'cost_center', existing.id, row.externalId ?? null, code, actorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await tx.costCenter.create({
|
||||
data: {
|
||||
legal_entity_id: legalEntity.id,
|
||||
code,
|
||||
name: row.name,
|
||||
enabled,
|
||||
archived_at: enabled ? null : new Date(),
|
||||
created_by: actorId ?? null,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
summary.created += 1;
|
||||
await upsertBinding(tx, 'cost_center', created.id, row.externalId ?? null, code, actorId);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function syncSuppliers(tx, rows, actorId) {
|
||||
const prepared = normalizePayloadRows(rows, (row) => String(row?.code ?? '').trim() || null);
|
||||
const summary = prepared.summary;
|
||||
const legalEntityCodes = Array.from(
|
||||
new Set(
|
||||
prepared.rows
|
||||
.map((row) => String(row.legalEntityCode ?? '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const legalEntities = await tx.legalEntity.findMany({
|
||||
where: { code: { in: legalEntityCodes } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
const legalEntityByCode = new Map(legalEntities.map((row) => [row.code, row]));
|
||||
const codes = prepared.rows.map((row) => String(row.code).trim());
|
||||
const existingRows = await tx.supplier.findMany({
|
||||
where: { code: { in: codes } },
|
||||
select: { id: true, code: true, enabled: true, archived_at: true },
|
||||
});
|
||||
const existingByCode = new Map(existingRows.map((row) => [row.code, row]));
|
||||
|
||||
for (const row of prepared.rows) {
|
||||
const code = String(row.code).trim();
|
||||
const legalEntityCode = String(row.legalEntityCode ?? '').trim() || null;
|
||||
const legalEntity = legalEntityCode ? legalEntityByCode.get(legalEntityCode) : null;
|
||||
if (legalEntityCode && !legalEntity) {
|
||||
addSkip(summary, code, 'legal_entity_missing');
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = existingByCode.get(code);
|
||||
const enabled = row.enabled !== false;
|
||||
const archivedAt = resolveArchivedState(enabled, existing?.archived_at ?? null);
|
||||
|
||||
if (existing) {
|
||||
const bindingCheck = await ensureBindingNotConflicted(tx, 'supplier', existing.id, row.externalId ?? null);
|
||||
if (!bindingCheck.ok) {
|
||||
addSkip(summary, code, bindingCheck.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.supplier.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: row.name,
|
||||
short_name: row.shortName ?? null,
|
||||
tax_no: row.taxNo ?? null,
|
||||
contact_name: row.contactName ?? null,
|
||||
contact_phone: row.contactPhone ?? null,
|
||||
payment_terms: row.paymentTerms ?? null,
|
||||
legal_entity_id: legalEntity?.id ?? null,
|
||||
enabled,
|
||||
archived_at: archivedAt,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
});
|
||||
summary.updated += 1;
|
||||
if (!enabled && (existing.enabled || !existing.archived_at)) {
|
||||
summary.archived += 1;
|
||||
}
|
||||
await upsertBinding(tx, 'supplier', existing.id, row.externalId ?? null, code, actorId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await tx.supplier.create({
|
||||
data: {
|
||||
code,
|
||||
name: row.name,
|
||||
short_name: row.shortName ?? null,
|
||||
tax_no: row.taxNo ?? null,
|
||||
contact_name: row.contactName ?? null,
|
||||
contact_phone: row.contactPhone ?? null,
|
||||
payment_terms: row.paymentTerms ?? null,
|
||||
legal_entity_id: legalEntity?.id ?? null,
|
||||
enabled,
|
||||
archived_at: enabled ? null : new Date(),
|
||||
created_by: actorId ?? null,
|
||||
updated_by: actorId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
summary.created += 1;
|
||||
await upsertBinding(tx, 'supplier', created.id, row.externalId ?? null, code, actorId);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function resolveResourceRows(data, resourceType) {
|
||||
if (resourceType === 'legal_entity') {
|
||||
return data?.legalEntities ?? [];
|
||||
}
|
||||
if (resourceType === 'cost_center') {
|
||||
return data?.costCenters ?? [];
|
||||
}
|
||||
return data?.suppliers ?? [];
|
||||
}
|
||||
|
||||
async function runResourceSync(prisma, resourceType, rows, actorId) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (resourceType === 'legal_entity') {
|
||||
return syncLegalEntities(tx, rows, actorId);
|
||||
}
|
||||
if (resourceType === 'cost_center') {
|
||||
return syncCostCenters(tx, rows, actorId);
|
||||
}
|
||||
return syncSuppliers(tx, rows, actorId);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncErpMasterData(prisma, config, options = {}) {
|
||||
const logger = options.logger ?? console;
|
||||
const actorId = options.actorId ?? null;
|
||||
const resources = normalizeRequestedResources(options.resources);
|
||||
|
||||
for (const resourceType of resources) {
|
||||
await prisma.$transaction((tx) => markSyncStateRunning(tx, resourceType, actorId));
|
||||
}
|
||||
|
||||
const pulled = await pullMasterDataFromErp(config, { resources });
|
||||
if (!pulled.ok) {
|
||||
for (const resourceType of resources) {
|
||||
await prisma.$transaction((tx) => markSyncStateFailed(tx, resourceType, actorId, pulled.message));
|
||||
}
|
||||
logger.error(
|
||||
{
|
||||
provider: 'erp',
|
||||
resourceType: resources.join(','),
|
||||
endpoint: config.erpMasterDataEndpoint ?? null,
|
||||
reason: pulled.code,
|
||||
},
|
||||
'ERP master data pull failed',
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: pulled,
|
||||
items: await listMasterDataSyncStates(prisma),
|
||||
};
|
||||
}
|
||||
|
||||
for (const resourceType of resources) {
|
||||
try {
|
||||
const summary = await runResourceSync(
|
||||
prisma,
|
||||
resourceType,
|
||||
resolveResourceRows(pulled.data, resourceType),
|
||||
actorId,
|
||||
);
|
||||
await prisma.$transaction((tx) => markSyncStateSucceeded(tx, resourceType, actorId, summary));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
provider: 'erp',
|
||||
resourceType,
|
||||
endpoint: config.erpMasterDataEndpoint ?? null,
|
||||
reason: error?.message ?? 'sync_failed',
|
||||
},
|
||||
'ERP master data resource sync failed',
|
||||
);
|
||||
await prisma.$transaction((tx) => markSyncStateFailed(tx, resourceType, actorId, error));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
error: null,
|
||||
items: await listMasterDataSyncStates(prisma),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ERP_MASTER_DATA_PROVIDER,
|
||||
ERP_MASTER_DATA_RESOURCES,
|
||||
listMasterDataSyncStates,
|
||||
mapSyncState,
|
||||
normalizeRequestedResources,
|
||||
syncErpMasterData,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户