all the haul

This commit is contained in:
2026-04-07 23:56:31 +00:00
parent 87cce83030
commit 24f303355b
244 changed files with 33743 additions and 11249 deletions
+44
View File
@@ -0,0 +1,44 @@
export { companyTranslation } from "./translations/company";
export { companyAddressTranslation } from "./translations/company-address";
export { contactTranslation } from "./translations/contact";
export { opportunityTranslation } from "./translations/opportunity";
export { opportunityStageTranslation } from "./translations/opportunity-stage";
export { opportunityTypeTranslation } from "./translations/opportunity-type";
export { opportunityStatusTranslation } from "./translations/opportunity-status";
export { catalogItemTranslation } from "./translations/catalog-item";
export { catalogItemTypeTranslation } from "./translations/catalog-item-type";
export { catalogCategoryTranslation } from "./translations/catalog-category";
export { catalogSubcategoryTranslation } from "./translations/catalog-subcategory";
export { catalogManufacturerTranslation } from "./translations/catalog-manufacturer";
export { warehouseBinTranslation } from "./translations/warehouse-bin";
export { productInventoryTranslation } from "./translations/product-inventory";
export { productDataTranslation } from "./translations/product-data.ts";
export { corporateLocationTranslation } from "./translations/corporate-location";
export { internalDepartmentTranslation } from "./translations/internal-department";
export { cwMemberTranslation } from "./translations/cw-member";
export { serviceTicketTypeTranslation } from "./translations/service-ticket-type";
export { serviceTicketBoardTranslation } from "./translations/service-ticket-board";
export { serviceTicketLocationTranslation } from "./translations/service-ticket-location";
export { serviceTicketSourceTranslation } from "./translations/service-ticket-source";
export { serviceTicketImpactTranslation } from "./translations/service-ticket-impact";
export { serviceTicketSeverityTranslation } from "./translations/service-ticket-severity";
export { serviceTicketPriorityTranslation } from "./translations/service-ticket-priority";
export { userTranslation } from "./translations/user";
export { serviceTicketTranslation } from "./translations/service-ticket";
export { serviceTicketNoteTranslation } from "./translations/service-ticket-note";
export { scheduleStatusTranslation } from "./translations/schedule-status";
export { scheduleTypeTranslation } from "./translations/schedule-type";
export { scheduleSpanTranslation } from "./translations/schedule-span";
export { scheduleTranslation } from "./translations/schedule";
export { taxCodeTranslation } from "./translations/tax-code";
// Context type exports
export type { TranslationContext } from "./translations/context";
export { createTranslationContext } from "./translations/context";
// Sync utilities
export { syncTableUpdates, disconnectSyncClients } from "./sync-by-table";
export {
executeFullDalpuriSync,
executeForcedIncrementalDalpuriSync,
} from "./sync";
+967
View File
@@ -0,0 +1,967 @@
import { PrismaMssql } from "@prisma/adapter-mssql";
import { PrismaPg } from "@prisma/adapter-pg";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { PrismaClient as CwPrismaClient } from "../generated/prisma/client";
import { PrismaClient as ApiPrismaClient } from "../../api/generated/prisma/client";
import {
catalogCategoryTranslation,
catalogItemTranslation,
catalogItemTypeTranslation,
catalogManufacturerTranslation,
catalogSubcategoryTranslation,
companyAddressTranslation,
companyTranslation,
contactTranslation,
corporateLocationTranslation,
createTranslationContext,
cwMemberTranslation,
internalDepartmentTranslation,
opportunityStatusTranslation,
opportunityTranslation,
opportunityTypeTranslation,
productDataTranslation,
productInventoryTranslation,
serviceTicketBoardTranslation,
serviceTicketImpactTranslation,
serviceTicketLocationTranslation,
serviceTicketNoteTranslation,
serviceTicketPriorityTranslation,
serviceTicketSeverityTranslation,
serviceTicketSourceTranslation,
serviceTicketTranslation,
serviceTicketTypeTranslation,
scheduleStatusTranslation,
scheduleTypeTranslation,
scheduleSpanTranslation,
scheduleTranslation,
userTranslation,
warehouseBinTranslation,
type TranslationContext,
} from "./index";
import { Translation, SkipRowError } from "./translations/types";
type Row = Record<string, unknown>;
type AnyTranslation = Translation<Row, Row, TranslationContext>;
type SyncTableConfig = {
sourceModel: string;
targetModel: string;
translation: AnyTranslation;
uniqueField: string;
lastUpdatedField: string;
sourceArgs?: Record<string, unknown>;
};
type SyncResult = {
insertedOrUpdated: number;
skipped: number;
failed: number;
};
const parseEnvFile = (path: string): Record<string, string> => {
const envData = readFileSync(path, "utf8");
const out: Record<string, string> = {};
for (const rawLine of envData.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const index = line.indexOf("=");
if (index <= 0) continue;
const key = line.slice(0, index).trim();
let value = line.slice(index + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
};
const resolveApiDatabaseUrl = (): string => {
if (process.env.API_DATABASE_URL) return process.env.API_DATABASE_URL;
if (process.env.OPTIMA_API_DATABASE_URL)
return process.env.OPTIMA_API_DATABASE_URL;
const candidates = [
resolve(import.meta.dir, "../../api/.env"),
resolve(process.cwd(), "../api/.env"),
];
for (const apiEnvPath of candidates) {
try {
const apiEnv = parseEnvFile(apiEnvPath);
if (apiEnv.DATABASE_URL) {
return apiEnv.DATABASE_URL;
}
} catch {
// Try next path candidate.
}
}
return "";
};
const parseJsonMapFromEnv = (envKey: string): Map<number, string> => {
const raw = process.env[envKey];
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as Record<string, string>;
const entries = Object.entries(parsed)
.map(([key, value]) => [Number.parseInt(key, 10), value] as const)
.filter(
([key, value]) => Number.isInteger(key) && typeof value === "string"
);
return new Map(entries);
} catch (error) {
console.warn(`Could not parse ${envKey} as JSON map. Ignoring.`, error);
return new Map();
}
};
const normalizeData = (data: Row): Row => {
const normalized: Row = {};
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
normalized[key] = value;
}
}
return normalized;
};
const sanitizeUserForeignKeys = (
data: Row,
context: TranslationContext,
targetModel: string
): Row => {
const sanitized = { ...data };
if (targetModel === "serviceTicket" && typeof sanitized.userId === "string") {
const id = context.usersByIdentifier.get(sanitized.userId as string);
sanitized.userId = id || null;
}
if (targetModel === "opportunity" && typeof sanitized.ownerId === "string") {
const id = context.usersByIdentifier.get(sanitized.ownerId as string);
sanitized.ownerId = id || null;
}
return sanitized;
};
const translateRow = (
row: Row,
translation: AnyTranslation,
context: TranslationContext
): Row => {
const out: Row = {};
for (const entry of translation.values) {
const fromKey = entry.from as string;
const toKey = entry.to as string;
const input = row[fromKey];
if (input === undefined) {
continue;
}
const output = entry.process
? entry.process(input as never, context, row)
: input;
out[toKey] = output as unknown;
}
return normalizeData(out);
};
const refreshContextFromApi = async (
apiPrisma: ApiPrismaClient,
context: TranslationContext
): Promise<void> => {
context.userIds.clear();
context.serviceTicketIds.clear();
context.opportunityIds.clear();
context.catalogItemIds.clear();
context.corporateLocationIds.clear();
context.usersByMemberRecId.clear();
context.userIdentifiersByMemberRecId.clear();
context.usersByIdentifier.clear();
context.serviceTicketBoardUidsById.clear();
context.opportunityStatusIds.clear();
context.scheduleStatusIds.clear();
context.scheduleTypeIds.clear();
context.scheduleSpanIds.clear();
const [
users,
boards,
serviceTickets,
opportunities,
catalogItems,
locations,
opportunityStatuses,
scheduleStatuses,
scheduleTypes,
scheduleSpans,
] = await Promise.all([
apiPrisma.user.findMany({
select: {
id: true,
cwMemberId: true,
cwIdentifier: true,
},
}),
apiPrisma.serviceTicketBoard.findMany({
select: {
id: true,
uid: true,
},
}),
apiPrisma.serviceTicket.findMany({
select: {
id: true,
},
}),
apiPrisma.opportunity.findMany({
select: {
id: true,
},
}),
apiPrisma.catalogItem.findMany({
select: {
id: true,
},
}),
apiPrisma.corporateLocation.findMany({
select: {
id: true,
},
}),
apiPrisma.opportunityStatus.findMany({
select: {
id: true,
},
}),
apiPrisma.scheduleStatus.findMany({
select: {
id: true,
},
}),
apiPrisma.scheduleType.findMany({
select: {
id: true,
},
}),
apiPrisma.scheduleSpan.findMany({
select: {
id: true,
},
}),
]);
const defaultOpportunityType = await apiPrisma.opportunityType.findFirst({
orderBy: { id: "asc" },
select: { id: true },
});
for (const user of users) {
context.userIds.add(user.id);
if (user.cwMemberId != null) {
context.usersByMemberRecId.set(user.cwMemberId, user.id);
if (user.cwIdentifier) {
context.userIdentifiersByMemberRecId.set(
user.cwMemberId,
user.cwIdentifier
);
}
}
if (user.cwIdentifier) {
context.usersByIdentifier.set(user.cwIdentifier, user.id);
}
}
for (const board of boards) {
context.serviceTicketBoardUidsById.set(board.id, board.uid);
}
for (const serviceTicket of serviceTickets) {
context.serviceTicketIds.add(serviceTicket.id);
}
for (const opportunity of opportunities) {
context.opportunityIds.add(opportunity.id);
}
for (const catalogItem of catalogItems) {
context.catalogItemIds.add(catalogItem.id);
}
for (const location of locations) {
context.corporateLocationIds.add(location.id);
}
for (const status of opportunityStatuses) {
context.opportunityStatusIds.add(status.id);
}
for (const status of scheduleStatuses) {
context.scheduleStatusIds.add(status.id);
}
for (const type of scheduleTypes) {
context.scheduleTypeIds.add(type.id);
}
for (const span of scheduleSpans) {
context.scheduleSpanIds.add(span.id);
}
context.defaultOpportunityTypeId = defaultOpportunityType?.id ?? null;
// Optional context fed from env-provided maps.
context.billingTypeByTicketId = parseJsonMapFromEnv(
"BILLING_TYPE_CONTEXT_JSON"
);
context.billingInstructionsByTicketId = parseJsonMapFromEnv(
"BILLING_INSTRUCTIONS_CONTEXT_JSON"
);
};
const refreshCustomFieldContextFromCw = async (
cwPrisma: CwPrismaClient,
context: TranslationContext
): Promise<void> => {
context.opportunityNarrativeByOpportunityId.clear();
context.productCustomByIvProductId.clear();
const [productCustomRows, opportunityCustomRows] = await Promise.all([
cwPrisma.$queryRawUnsafe<Array<Record<string, unknown>>>(`
SELECT iv.RecID, iv.ConfigUIDF as fieldName, iv.ConfigValue as fieldValue
FROM dbo.iV_Product iv
`),
cwPrisma.$queryRawUnsafe<Array<Record<string, unknown>>>(`
SELECT so.RecID, so.SalesPipelineUIDF as fieldName, so.OpportunitySalesNotes as fieldValue
FROM dbo.opportunity so
`),
]);
for (const row of productCustomRows) {
const productId = row.RecID;
const fieldName = row.fieldName;
const fieldValue = row.fieldValue;
if (
typeof productId === "number" &&
typeof fieldName === "string" &&
typeof fieldValue === "string"
) {
if (!context.productCustomByIvProductId.has(productId)) {
context.productCustomByIvProductId.set(productId, new Map());
}
context.productCustomByIvProductId
.get(productId)
?.set(fieldName.toLowerCase().trim(), fieldValue.trim());
}
}
for (const row of opportunityCustomRows) {
const opportunityId = row.RecID;
const fieldName = row.fieldName;
const fieldValue = row.fieldValue;
if (
typeof opportunityId === "number" &&
typeof fieldName === "string" &&
typeof fieldValue === "string"
) {
if (fieldName.toLowerCase().includes("narrative")) {
context.opportunityNarrativeByOpportunityId.set(
opportunityId,
fieldValue
);
}
}
}
};
const sanitizeModelData = (
data: Row,
targetModel: string,
context: TranslationContext
): Row => {
const sanitized = { ...data };
if (targetModel === "warehouseBin") {
if (sanitized.minQuantity == null) sanitized.minQuantity = 0;
if (sanitized.maxQuantity == null) sanitized.maxQuantity = 0;
}
if (targetModel === "serviceTicket") {
const boardId = sanitized.serviceTicketBoardId;
if (typeof boardId === "string") {
const parsed = Number.parseInt(boardId, 10);
sanitized.serviceTicketBoardId = Number.isNaN(parsed) ? null : parsed;
}
}
if (targetModel === "opportunity") {
if (sanitized.typeId == null && context.defaultOpportunityTypeId != null) {
sanitized.typeId = context.defaultOpportunityTypeId;
}
if (
sanitized.statusId != null &&
!context.opportunityStatusIds.has(sanitized.statusId as number)
) {
sanitized.statusId = null;
}
}
if (targetModel === "schedule") {
// Nullify statusId if the referenced ScheduleStatus hasn't synced yet
if (
sanitized.statusId != null &&
!context.scheduleStatusIds.has(sanitized.statusId as number)
) {
sanitized.statusId = null;
}
// Nullify typeId if the referenced ScheduleType hasn't synced yet
if (
sanitized.typeId != null &&
!context.scheduleTypeIds.has(sanitized.typeId as number)
) {
sanitized.typeId = null;
}
// Nullify scheduleSpanId if the referenced ScheduleSpan hasn't synced yet
if (
sanitized.scheduleSpanId != null &&
!context.scheduleSpanIds.has(sanitized.scheduleSpanId as number)
) {
sanitized.scheduleSpanId = null;
}
}
return sanitized;
};
const asRecord = (value: unknown): Record<string, unknown> | null => {
if (typeof value !== "object" || value == null) {
return null;
}
return value as Record<string, unknown>;
};
const parseFieldsFromErrorMessage = (message: string): string[] => {
const fields = new Set<string>();
// Handles patterns like (`"cwIdentifier"`) and (`login`)
for (const match of message.matchAll(/`\"([^\"]+)\"`|`([^`]+)`/g)) {
const candidate = (match[1] ?? match[2] ?? "").trim();
if (!candidate) continue;
// Skip common non-field tokens that can appear in error messages.
if (candidate === "targetDelegate.upsert()" || candidate === "invocation") {
continue;
}
fields.add(candidate);
}
return [...fields];
};
const getUniqueConstraintFields = (error: unknown): string[] => {
const errorRecord = asRecord(error);
if (!errorRecord || errorRecord.code !== "P2002") {
return [];
}
const meta = asRecord(errorRecord.meta);
if (!meta) {
return [];
}
const target = meta.target;
if (Array.isArray(target)) {
return target.filter((field): field is string => typeof field === "string");
}
if (typeof target === "string") {
return [target];
}
if (typeof errorRecord.message === "string") {
return parseFieldsFromErrorMessage(errorRecord.message);
}
return [];
};
const formatValue = (value: unknown): string => {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "string") {
return JSON.stringify(value);
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value == null
) {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return "[unserializable]";
}
};
const formatUniqueConstraintError = (
error: unknown,
data: Row | null
): string => {
const fields = getUniqueConstraintFields(error);
if (fields.length === 0) {
return "Unique constraint failed.";
}
const fieldValues = fields.map((field) => {
const value = data ? data[field] : undefined;
return `${field}=${formatValue(value)}`;
});
return `Unique constraint failed on field(s): ${fields.join(
", "
)}. Values: ${fieldValues.join(", ")}`;
};
const getConfigForTable = (table: string): SyncTableConfig | null => {
const configMap: Record<string, SyncTableConfig> = {
member: {
sourceModel: "member",
targetModel: "user",
translation: userTranslation as unknown as AnyTranslation,
uniqueField: "cwMemberId",
lastUpdatedField: "lastUpdatedUtc",
},
cwMember: {
sourceModel: "member",
targetModel: "cwMember",
translation: cwMemberTranslation as unknown as AnyTranslation,
uniqueField: "cwMemberId",
lastUpdatedField: "lastUpdatedUtc",
},
company: {
sourceModel: "company",
targetModel: "company",
translation: companyTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdate",
},
companyAddress: {
sourceModel: "companyAddress",
targetModel: "companyAddress",
translation: companyAddressTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
contact: {
sourceModel: "contact",
targetModel: "contact",
translation: contactTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
ownerLevel: {
sourceModel: "ownerLevel",
targetModel: "corporateLocation",
translation: corporateLocationTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
department: {
sourceModel: "department",
targetModel: "internalDepartment",
translation: internalDepartmentTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUTC",
},
productType: {
sourceModel: "productType",
targetModel: "catalogItemType",
translation: catalogItemTypeTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
productCategory: {
sourceModel: "productCategory",
targetModel: "catalogCategory",
translation: catalogCategoryTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
productSubcategory: {
sourceModel: "productSubcategory",
targetModel: "catalogSubcategory",
translation: catalogSubcategoryTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
manufacturer: {
sourceModel: "manufacturer",
targetModel: "catalogManufacturer",
translation: catalogManufacturerTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
warehouseBin: {
sourceModel: "warehouseBin",
targetModel: "warehouseBin",
translation: warehouseBinTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
productCatalog: {
sourceModel: "productCatalog",
targetModel: "catalogItem",
translation: catalogItemTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
productInventory: {
sourceModel: "productInventory",
targetModel: "productInventory",
translation: productInventoryTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srType: {
sourceModel: "srType",
targetModel: "serviceTicketType",
translation: serviceTicketTypeTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srLocation: {
sourceModel: "srLocation",
targetModel: "serviceTicketLocation",
translation:
serviceTicketLocationTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srSource: {
sourceModel: "srSource",
targetModel: "serviceTicketSource",
translation: serviceTicketSourceTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srImpact: {
sourceModel: "srImpact",
targetModel: "serviceTicketImpact",
translation: serviceTicketImpactTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srSeverity: {
sourceModel: "srSeverity",
targetModel: "serviceTicketSeverity",
translation:
serviceTicketSeverityTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srUrgency: {
sourceModel: "srUrgency",
targetModel: "serviceTicketPriority",
translation:
serviceTicketPriorityTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
srBoard: {
sourceModel: "srBoard",
targetModel: "serviceTicketBoard",
translation: serviceTicketBoardTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
soType: {
sourceModel: "soType",
targetModel: "opportunityType",
translation: opportunityTypeTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
soOppStatus: {
sourceModel: "soOppStatus",
targetModel: "opportunityStatus",
translation: opportunityStatusTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
opportunity: {
sourceModel: "opportunity",
targetModel: "opportunity",
translation: opportunityTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
sourceArgs: {
include: {
members: {
select: {
memberRecId: true,
primarySalesFlag: true,
secondarySalesFlag: true,
},
},
},
},
},
srService: {
sourceModel: "srService",
targetModel: "serviceTicket",
translation: serviceTicketTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
iV_Product: {
sourceModel: "iV_Product",
targetModel: "productData",
translation: productDataTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUTC",
},
ticketNote: {
sourceModel: "ticketNote",
targetModel: "serviceTicketNote",
translation: serviceTicketNoteTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdatedUtc",
},
scheduleStatus: {
sourceModel: "scheduleStatus",
targetModel: "scheduleStatus",
translation: scheduleStatusTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdateUtc",
},
scheduleType: {
sourceModel: "scheduleType",
targetModel: "scheduleType",
translation: scheduleTypeTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdateUtc",
},
scheduleSpan: {
sourceModel: "scheduleSpan",
targetModel: "scheduleSpan",
translation: scheduleSpanTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "scheduleSpanRecId",
},
schedule: {
sourceModel: "schedule",
targetModel: "schedule",
translation: scheduleTranslation as unknown as AnyTranslation,
uniqueField: "id",
lastUpdatedField: "lastUpdateUtc",
},
};
return configMap[table] ?? null;
};
let _cwPrisma: CwPrismaClient | null = null;
let _apiPrisma: ApiPrismaClient | null = null;
const getClients = (): {
cwPrisma: CwPrismaClient;
apiPrisma: ApiPrismaClient;
} => {
if (!_cwPrisma || !_apiPrisma) {
const cwDatabaseUrl =
process.env.CW_DATABASE_URL || process.env.DATABASE_URL;
const apiDatabaseUrl = resolveApiDatabaseUrl();
if (!cwDatabaseUrl) {
throw new Error(
"Missing CW DB connection string. Set CW_DATABASE_URL or DATABASE_URL."
);
}
if (!apiDatabaseUrl) {
throw new Error(
"Missing API DB connection string. Set API_DATABASE_URL/OPTIMA_API_DATABASE_URL or ensure ../api/.env has DATABASE_URL."
);
}
_cwPrisma = new CwPrismaClient({ adapter: new PrismaMssql(cwDatabaseUrl) });
_apiPrisma = new ApiPrismaClient({
adapter: new PrismaPg({ connectionString: apiDatabaseUrl }),
});
}
return { cwPrisma: _cwPrisma, apiPrisma: _apiPrisma };
};
export async function disconnectSyncClients(): Promise<void> {
await Promise.all([_cwPrisma?.$disconnect(), _apiPrisma?.$disconnect()]);
_cwPrisma = null;
_apiPrisma = null;
}
/**
* Syncs a specific number of records from a CW table to the API database.
* Records are pulled ordered by lastUpdatedUTC in descending order (most recent first).
*
* @param table - The name of the source table (e.g., 'opportunity', 'srService', 'company')
* @param numRec - The number of records to pull and sync
* @returns SyncResult containing counts of inserted/updated, skipped, and failed records
*/
export async function syncTableUpdates(
table: string,
numRec: number
): Promise<SyncResult> {
const config = getConfigForTable(table);
if (!config) {
throw new Error(
`Unsupported table: ${table}. No sync configuration found.`
);
}
const { cwPrisma, apiPrisma } = getClients();
const context = createTranslationContext();
// Refresh context needed for translations
await refreshContextFromApi(apiPrisma, context);
if (
config.targetModel === "opportunity" ||
config.targetModel === "productData"
) {
await refreshCustomFieldContextFromCw(cwPrisma, context);
}
// Build the source args with lastUpdatedField ordering and take limit
const sourceArgs = {
...config.sourceArgs,
take: numRec,
orderBy: {
[config.lastUpdatedField]: "desc",
},
};
const sourceDelegate = (
cwPrisma as unknown as Record<string, { findMany: Function }>
)[config.sourceModel];
const targetDelegate = (
apiPrisma as unknown as Record<string, { upsert: Function }>
)[config.targetModel];
if (!sourceDelegate) {
throw new Error(`CW delegate not found: ${config.sourceModel}`);
}
if (!targetDelegate) {
throw new Error(`API delegate not found: ${config.targetModel}`);
}
// Fetch the records
const rows = (await sourceDelegate.findMany(sourceArgs)) as Row[];
let insertedOrUpdated = 0;
let skipped = 0;
let failed = 0;
let sampleErrorsPrinted = 0;
// Process each row
for (const row of rows) {
let translatedData: Row | null = null;
try {
const translated = translateRow(row, config.translation, context);
const userSanitized = sanitizeUserForeignKeys(
translated,
context,
config.targetModel
);
const data = sanitizeModelData(
userSanitized,
config.targetModel,
context
);
translatedData = data;
const uniqueValue = data[config.uniqueField];
if (
uniqueValue === undefined ||
uniqueValue === null ||
uniqueValue === ""
) {
skipped += 1;
continue;
}
await targetDelegate.upsert({
where: {
[config.uniqueField]: uniqueValue,
},
create: data,
update: data,
});
insertedOrUpdated += 1;
} catch (error) {
if (error instanceof SkipRowError) {
skipped += 1;
continue;
}
failed += 1;
if (sampleErrorsPrinted < 5) {
sampleErrorsPrinted += 1;
const uniqueFields = getUniqueConstraintFields(error);
const message =
uniqueFields.length > 0
? formatUniqueConstraintError(error, translatedData)
: error instanceof Error
? error.message
: "Unknown row sync error";
console.error(
`Failed row in ${config.sourceModel} -> ${config.targetModel}:`,
message
);
}
}
}
if (failed > sampleErrorsPrinted) {
console.error(
`${config.sourceModel}: suppressed ${
failed - sampleErrorsPrinted
} additional row errors`
);
}
return { insertedOrUpdated, skipped, failed };
}
+69
View File
@@ -0,0 +1,69 @@
import { syncTableUpdates } from "./index";
/**
* Example usage of syncTableUpdates
*
* Usage from CLI:
* bun run src/sync-table-updates.ts <table-name> <num-records>
*
* Example:
* bun run src/sync-table-updates.ts opportunity 100
* bun run src/sync-table-updates.ts srService 50
* bun run src/sync-table-updates.ts company 10
*/
const main = async () => {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error("Usage: bun run src/sync-table-updates.ts <table> <numRec>");
console.error("\nSupported tables:");
console.error(
" - member, cwMember, company, companyAddress, contact, ownerLevel"
);
console.error(
" - department, productType, productCategory, productSubcategory"
);
console.error(
" - manufacturer, warehouseBin, productCatalog, productInventory"
);
console.error(
" - srType, srLocation, srSource, srImpact, srSeverity, srUrgency"
);
console.error(
" - srBoard, soType, soOppStatus, opportunity, srService, iV_Product"
);
console.error(" - ticketNote");
console.error("\nExample: bun run src/sync-table-updates.ts opportunity 100");
process.exit(1);
}
const table = args[0];
const numRec = Number.parseInt(args[1], 10);
if (Number.isNaN(numRec) || numRec <= 0) {
console.error("Error: numRec must be a positive integer");
process.exit(1);
}
try {
console.log(`Syncing ${numRec} records from table: ${table}`);
console.log("Pulling records ordered by lastUpdatedUTC (most recent first)");
console.log("");
const result = await syncTableUpdates(table, numRec);
console.log("\nSync completed!");
console.log(`Inserted/Updated: ${result.insertedOrUpdated}`);
console.log(`Skipped: ${result.skipped}`);
console.log(`Failed: ${result.failed}`);
console.log(
`Total: ${result.insertedOrUpdated + result.skipped + result.failed}`
);
} catch (error) {
console.error("Sync failed:", error);
process.exit(1);
}
};
main();
+838 -58
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
import { describe, expect, test } from "bun:test";
import { SkipRowError } from "../types";
import { createTranslationContext, type TranslationContext } from "../context";
// ─── Testonly translator helper ──────────────────────────────────────────
// We cannot import Translation<kF,kT,kC> generically, but every translator
// exposes a `.values` array. We walk the entries exactly the way the real
// `translateRow` function does.
type AnyTranslation = {
values: Array<{
from: string;
to: string;
process?: (value: any, context: any, row: any) => any;
}>;
};
function translateRow(
row: Record<string, unknown>,
translation: AnyTranslation,
context: TranslationContext
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const entry of translation.values) {
const input = row[entry.from];
if (input === undefined) continue;
out[entry.to] = entry.process
? entry.process(input, context, row)
: input;
}
return out;
}
function expectSkipRow(fn: () => void): string {
try {
fn();
throw new Error("Expected SkipRowError but nothing was thrown");
} catch (err) {
expect(err).toBeInstanceOf(SkipRowError);
return (err as SkipRowError).message;
}
}
// ─── TranslationContext factory ───────────────────────────────────────────
function makeContext(overrides?: Partial<TranslationContext>): TranslationContext {
const ctx = createTranslationContext();
if (overrides) Object.assign(ctx, overrides);
return ctx;
}
// ═══════════════════════════════════════════════════════════════════════════
// User Translator
// ═══════════════════════════════════════════════════════════════════════════
describe("userTranslation", () => {
// Lazyload to sidestep toplevel Prisma import issues in test env.
let userTranslation: AnyTranslation;
test("module loads", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
expect(userTranslation).toBeDefined();
});
test("valid email is normalised", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
const ctx = makeContext();
const row = {
emailAddress: " John@Example.COM ",
firstName: "John",
lastName: "Doe",
memberRecId: 42,
memberId: "jdoe",
memberClass: "F",
inactiveFlag: false,
dateHire: new Date("2024-01-01"),
lastUpdatedUtc: new Date("2024-06-01"),
};
const result = translateRow(row, userTranslation, ctx);
expect(result.login).toBe("jdoe@cw.local");
expect(result.email).toBe("john@example.com");
expect(result.firstName).toBe("John");
expect(result.active).toBe(true);
expect(result.cwMemberId).toBe(42);
expect(result.cwIdentifier).toBe("jdoe");
});
test("missing email generates placeholder", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
const ctx = makeContext();
const result = translateRow(
{ emailAddress: null, firstName: "A", lastName: "B", memberRecId: 1, memberId: "a", memberClass: "F", inactiveFlag: false, lastUpdatedUtc: new Date(), dateHire: null },
userTranslation,
ctx
);
expect(result.email).toBe("a@cw.local");
});
test("email without @ generates placeholder", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
const ctx = makeContext();
const result = translateRow(
{ emailAddress: "nodomain", firstName: "A", lastName: "B", memberRecId: 1, memberId: "a", memberClass: "F", inactiveFlag: false, lastUpdatedUtc: new Date(), dateHire: null },
userTranslation,
ctx
);
expect(result.email).toBe("a@cw.local");
});
test("empty firstName falls back to Unknown", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
const ctx = makeContext();
const result = translateRow(
{ emailAddress: "x@y.com", firstName: "", lastName: "", memberRecId: 1, memberId: "x", inactiveFlag: false, lastUpdatedUtc: new Date(), dateHire: null },
userTranslation,
ctx
);
expect(result.firstName).toBe("Unknown");
expect(result.lastName).toBe("Unknown");
});
test("inactiveFlag=true maps to active=false", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
const ctx = makeContext();
const result = translateRow(
{ emailAddress: "x@y.com", firstName: "X", lastName: "Y", memberRecId: 1, memberId: "x", inactiveFlag: true, lastUpdatedUtc: new Date(), dateHire: null },
userTranslation,
ctx
);
expect(result.active).toBe(false);
});
test("createdAt falls back to lastUpdatedUtc when dateHire is null", async () => {
const mod = await import("../user");
userTranslation = mod.userTranslation as unknown as AnyTranslation;
const ctx = makeContext();
const updated = new Date("2025-03-01");
const result = translateRow(
{ emailAddress: "x@y.com", firstName: "X", lastName: "Y", memberRecId: 1, memberId: "x", inactiveFlag: false, lastUpdatedUtc: updated, dateHire: null },
userTranslation,
ctx
);
expect(result.createdAt).toEqual(updated);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Opportunity Translator
// ═══════════════════════════════════════════════════════════════════════════
describe("opportunityTranslation", () => {
let opportunityTranslation: AnyTranslation;
test("module loads", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
expect(opportunityTranslation).toBeDefined();
});
test("interest maps 1->COLD, 2->WARM, 3->HOT", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const ctx = makeContext({ defaultOpportunityTypeId: 1 });
for (const [input, expected] of [
[1, "COLD"],
[2, "WARM"],
[3, "HOT"],
] as const) {
const result = translateRow(
{ opportunityRecId: 100, soInterestRecId: input, soTypeRecId: 1, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 50, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [] },
opportunityTranslation,
ctx
);
expect(result.interest).toBe(expected);
}
});
test("interest value 0 maps to COLD", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const ctx = makeContext({ defaultOpportunityTypeId: 1 });
const result = translateRow(
{ opportunityRecId: 100, soInterestRecId: 0, soTypeRecId: 1, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 0, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [] },
opportunityTranslation,
ctx
);
expect(result.interest).toBe("COLD");
});
test("interest value 4+ maps to HOT", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const ctx = makeContext({ defaultOpportunityTypeId: 1 });
const result = translateRow(
{ opportunityRecId: 100, soInterestRecId: 4, soTypeRecId: 1, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 0, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [] },
opportunityTranslation,
ctx
);
expect(result.interest).toBe("HOT");
});
test("null interest returns null", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const ctx = makeContext({ defaultOpportunityTypeId: 1 });
const result = translateRow(
{ opportunityRecId: 100, soInterestRecId: null, soTypeRecId: 1, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 0, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [] },
opportunityTranslation,
ctx
);
expect(result.interest).toBeNull();
});
test("missing typeId with default fallback succeeds", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const ctx = makeContext({ defaultOpportunityTypeId: 99 });
const result = translateRow(
{ opportunityRecId: 100, soInterestRecId: null, soTypeRecId: null, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 0, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [] },
opportunityTranslation,
ctx
);
expect(result.typeId).toBe(99);
});
test("missing typeId with no default triggers skipRow", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const ctx = makeContext({ defaultOpportunityTypeId: null });
expectSkipRow(() => {
translateRow(
{ opportunityRecId: 100, soInterestRecId: null, soTypeRecId: null, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 0, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [] },
opportunityTranslation,
ctx
);
});
});
test("primary/secondary sales reps resolved from members", async () => {
const mod = await import("../opportunity");
opportunityTranslation = mod.opportunityTranslation as unknown as AnyTranslation;
const identMap = new Map<number, string>([[10, "jdoe"], [20, "jsmith"]]);
const ctx = makeContext({
defaultOpportunityTypeId: 1,
userIdentifiersByMemberRecId: identMap,
});
const result = translateRow(
{ opportunityRecId: 100, soInterestRecId: null, soTypeRecId: 1, opportunityName: "T", notes: null, soPipelineRecId: null, soOppStatusRecId: null, probabilityToClose: 0, source: null, companyRecId: 1, contactRecId: 1, companyAddressRecId: null, poNumber: null, dateCloseExpected: null, datePipelineChange: null, dateBecameLead: null, dateClosed: null, oldCloseFlag: false, closedBy: null, updatedBy: "admin", enteredBy: "admin", dateBecameLeadUtc: new Date(), lastUpdatedUtc: new Date(), members: [{ memberRecId: 10, primarySalesFlag: true, secondarySalesFlag: false }, { memberRecId: 20, primarySalesFlag: false, secondarySalesFlag: true }] },
opportunityTranslation,
ctx
);
expect(result.primarySalesRepId).toBe("jdoe");
expect(result.secondarySalesRepId).toBe("jsmith");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Service Ticket Translator
// ═══════════════════════════════════════════════════════════════════════════
describe("serviceTicketTranslation", () => {
let serviceTicketTranslation: AnyTranslation;
test("module loads", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
expect(serviceTicketTranslation).toBeDefined();
});
test("valid row translates correctly", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
const ctx = makeContext({
usersByMemberRecId: new Map([[5, "user-abc"]]),
usersByIdentifier: new Map([["admin", "user-admin"]]),
billingTypeByTicketId: new Map([[42, "Standard"]]),
billingInstructionsByTicketId: new Map([[42, "Bill monthly"]]),
});
const result = translateRow(
{
srServiceRecId: 42, summary: "Server is down", srLocationRecId: 1, srSourceRecId: 2,
srUrgencyRecId: 3, srSeverityRecId: 4, srImpactRecId: 5, srBoardRecId: 10,
companyRecId: 100, contactRecId: 200, companyAddressRecId: 300,
billingCompanyRecId: null, billingAddressRecId: null,
ticketOwnerRecId: 5, enteredBy: "admin", updatedBy: "admin", closedBy: null,
dateEnteredUtc: new Date("2025-01-01"), lastUpdatedUtc: new Date("2025-06-01"),
dateClosedUtc: null, isClosedFlag: false, billMethod: "A",
poNumber: "PO123", billingAmount: 500, rejectedFlag: false, publishFlag: true, redFlag: false,
},
serviceTicketTranslation,
ctx
);
expect(result.id).toBe(42);
expect(result.summary).toBe("Server is down");
expect(result.severityId).toBe(4);
expect(result.ticketOwnerId).toBe("user-abc");
expect(result.createdById).toBe("user-admin");
expect(result.billingMethod).toBe("ACTUAL_RATES");
expect(result.billingType).toBe("STANDARD");
expect(result.billingInstructions).toBe("Bill monthly");
});
test("empty summary triggers skipRow", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
const ctx = makeContext();
expectSkipRow(() => {
translateRow(
{ srServiceRecId: 1, summary: "", srLocationRecId: 1, srSourceRecId: 1, srSeverityRecId: 1, srImpactRecId: 1, srBoardRecId: null, companyRecId: null, contactRecId: null, companyAddressRecId: null, billingCompanyRecId: null, billingAddressRecId: null, ticketOwnerRecId: null, enteredBy: null, updatedBy: null, closedBy: null, dateEnteredUtc: null, lastUpdatedUtc: null, dateClosedUtc: null, isClosedFlag: false, billMethod: null, srUrgencyRecId: null, poNumber: null, billingAmount: null, rejectedFlag: false, publishFlag: false, redFlag: false },
serviceTicketTranslation,
ctx
);
});
});
test("null locationId triggers skipRow", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
const ctx = makeContext();
expectSkipRow(() => {
translateRow(
{ srServiceRecId: 1, summary: "problem", srLocationRecId: null, srSourceRecId: 1, srSeverityRecId: 1, srImpactRecId: 1, srBoardRecId: null, companyRecId: null, contactRecId: null, companyAddressRecId: null, billingCompanyRecId: null, billingAddressRecId: null, ticketOwnerRecId: null, enteredBy: null, updatedBy: null, closedBy: null, dateEnteredUtc: null, lastUpdatedUtc: null, dateClosedUtc: null, isClosedFlag: false, billMethod: null, srUrgencyRecId: null, poNumber: null, billingAmount: null, rejectedFlag: false, publishFlag: false, redFlag: false },
serviceTicketTranslation,
ctx
);
});
});
test("null dates return null instead of epoch", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
const ctx = makeContext({
billingTypeByTicketId: new Map(),
billingInstructionsByTicketId: new Map(),
});
const result = translateRow(
{
srServiceRecId: 1, summary: "test", srLocationRecId: 1, srSourceRecId: 1,
srSeverityRecId: 1, srImpactRecId: 1, srBoardRecId: null,
companyRecId: null, contactRecId: null, companyAddressRecId: null,
billingCompanyRecId: null, billingAddressRecId: null,
ticketOwnerRecId: null, enteredBy: null, updatedBy: null, closedBy: null,
dateEnteredUtc: null, lastUpdatedUtc: null, dateClosedUtc: null,
isClosedFlag: false, billMethod: null, srUrgencyRecId: null,
poNumber: null, billingAmount: null, rejectedFlag: false, publishFlag: false, redFlag: false,
},
serviceTicketTranslation,
ctx
);
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
test("billing method maps correctly", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
const ctx = makeContext({
billingTypeByTicketId: new Map(),
billingInstructionsByTicketId: new Map(),
});
const base = {
srServiceRecId: 1, summary: "test", srLocationRecId: 1, srSourceRecId: 1,
srSeverityRecId: 1, srImpactRecId: 1, srBoardRecId: null,
companyRecId: null, contactRecId: null, companyAddressRecId: null,
billingCompanyRecId: null, billingAddressRecId: null,
ticketOwnerRecId: null, enteredBy: null, updatedBy: null, closedBy: null,
dateEnteredUtc: new Date(), lastUpdatedUtc: new Date(), dateClosedUtc: null,
isClosedFlag: false, srUrgencyRecId: null,
poNumber: null, billingAmount: 0, rejectedFlag: false, publishFlag: false, redFlag: false,
};
for (const [input, expected] of [
["A", "ACTUAL_RATES"],
["F", "FIXED_FEE"],
["N", "NOT_TO_EXCEED"],
["O", "OVERRIDE_RATE"],
[null, "ACTUAL_RATES"],
["UNKNOWN", "ACTUAL_RATES"],
] as const) {
const result = translateRow({ ...base, billMethod: input }, serviceTicketTranslation, ctx);
expect(result.billingMethod).toBe(expected);
}
});
test("missing billingType defaults to STANDARD", async () => {
const mod = await import("../service-ticket");
serviceTicketTranslation = mod.serviceTicketTranslation as unknown as AnyTranslation;
const ctx = makeContext({
billingTypeByTicketId: new Map(), // No entry for ticket 999
billingInstructionsByTicketId: new Map(),
});
const result = translateRow(
{
srServiceRecId: 999, summary: "test", srLocationRecId: 1, srSourceRecId: 1,
srSeverityRecId: 1, srImpactRecId: 1, srBoardRecId: null,
companyRecId: null, contactRecId: null, companyAddressRecId: null,
billingCompanyRecId: null, billingAddressRecId: null,
ticketOwnerRecId: null, enteredBy: null, updatedBy: null, closedBy: null,
dateEnteredUtc: new Date(), lastUpdatedUtc: new Date(), dateClosedUtc: null,
isClosedFlag: false, billMethod: null, srUrgencyRecId: null,
poNumber: null, billingAmount: 0, rejectedFlag: false, publishFlag: false, redFlag: false,
},
serviceTicketTranslation,
ctx
);
expect(result.billingType).toBe("STANDARD");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Service Ticket Note Translator
// ═══════════════════════════════════════════════════════════════════════════
describe("serviceTicketNoteTranslation", () => {
let serviceTicketNoteTranslation: AnyTranslation;
test("module loads", async () => {
const mod = await import("../service-ticket-note");
serviceTicketNoteTranslation = mod.serviceTicketNoteTranslation as unknown as AnyTranslation;
expect(serviceTicketNoteTranslation).toBeDefined();
});
test("valid note translates correctly", async () => {
const mod = await import("../service-ticket-note");
serviceTicketNoteTranslation = mod.serviceTicketNoteTranslation as unknown as AnyTranslation;
const ctx = makeContext({
serviceTicketIds: new Set([42]),
usersByMemberRecId: new Map([[10, "user-abc"]]),
});
const result = translateRow(
{ ticketNoteRecId: 1, srServiceRecId: 42, notesMarkdown: "# Title", notes: "Title", memberRecId: 10, problemFlag: true, resolutionFlag: false, internalAnalysisFlag: false, internalMemberFlag: false, mergedFlag: false, bundledFlag: false, createdByParentFlag: false, dateCreatedUtc: new Date("2025-01-01"), lastUpdatedUtc: new Date("2025-06-01") },
serviceTicketNoteTranslation,
ctx
);
expect(result.serviceTicketId).toBe(42);
expect(result.authorId).toBe("user-abc");
expect(result.notesMd).toBe("# Title");
});
test("missing parent ticket triggers skipRow", async () => {
const mod = await import("../service-ticket-note");
serviceTicketNoteTranslation = mod.serviceTicketNoteTranslation as unknown as AnyTranslation;
const ctx = makeContext({
serviceTicketIds: new Set([99]), // 42 NOT in set
usersByMemberRecId: new Map([[10, "user-abc"]]),
});
expectSkipRow(() => {
translateRow(
{ ticketNoteRecId: 1, srServiceRecId: 42, notesMarkdown: "", notes: "", memberRecId: 10, problemFlag: false, resolutionFlag: false, internalAnalysisFlag: false, internalMemberFlag: false, mergedFlag: false, bundledFlag: false, createdByParentFlag: false, dateCreatedUtc: null, lastUpdatedUtc: null },
serviceTicketNoteTranslation,
ctx
);
});
});
test("unknown memberRecId triggers skipRow", async () => {
const mod = await import("../service-ticket-note");
serviceTicketNoteTranslation = mod.serviceTicketNoteTranslation as unknown as AnyTranslation;
const ctx = makeContext({
serviceTicketIds: new Set([42]),
usersByMemberRecId: new Map(), // empty
});
expectSkipRow(() => {
translateRow(
{ ticketNoteRecId: 1, srServiceRecId: 42, notesMarkdown: "", notes: "", memberRecId: 999, problemFlag: false, resolutionFlag: false, internalAnalysisFlag: false, internalMemberFlag: false, mergedFlag: false, bundledFlag: false, createdByParentFlag: false, dateCreatedUtc: null, lastUpdatedUtc: null },
serviceTicketNoteTranslation,
ctx
);
});
});
test("null dates return null", async () => {
const mod = await import("../service-ticket-note");
serviceTicketNoteTranslation = mod.serviceTicketNoteTranslation as unknown as AnyTranslation;
const ctx = makeContext({
serviceTicketIds: new Set([42]),
usersByMemberRecId: new Map([[10, "user-abc"]]),
});
const result = translateRow(
{ ticketNoteRecId: 1, srServiceRecId: 42, notesMarkdown: "", notes: "", memberRecId: 10, problemFlag: false, resolutionFlag: false, internalAnalysisFlag: false, internalMemberFlag: false, mergedFlag: false, bundledFlag: false, createdByParentFlag: false, dateCreatedUtc: null, lastUpdatedUtc: null },
serviceTicketNoteTranslation,
ctx
);
expect(result.createdAt).toBeNull();
expect(result.updatedAt).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Product Data Translator
// ═══════════════════════════════════════════════════════════════════════════
describe("productDataTranslation", () => {
let productDataTranslation: AnyTranslation;
test("module loads", async () => {
const mod = await import("../product-data");
productDataTranslation = mod.productDataTranslation as unknown as AnyTranslation;
expect(productDataTranslation).toBeDefined();
});
test("missing catalog item triggers skipRow", async () => {
const mod = await import("../product-data");
productDataTranslation = mod.productDataTranslation as unknown as AnyTranslation;
const ctx = makeContext({
catalogItemIds: new Set([1]), // 999 NOT in set
corporateLocationIds: new Set([1]),
});
expectSkipRow(() => {
translateRow(
{ ivProductRecId: 1, quantity: 1, catalogRecId: 999, ownerLevelRecId: 1, internalNote: null, shortDescription: null, sequenceNumber: null, unitPrice: 10, unitCost: 5, listPrice: 15, discountAmount: 0, recurringRevenue: 0, recurringCost: 0, qtyPicked: 0, qtyShipped: 0, cancelReason: null, quantityCancelled: null, billableFlag: true, taxableFlag: false, invoiceFlag: false, recurringFlag: false, poApprovedFlag: false, calcPriceFlag: false, calcCostFlag: false, cancelFlag: false, srServiceRecId: null, opportunityRecId: null, updatedBy: null, enteredBy: null, closedBy: null, cancelBy: null, dateClosedUtc: null, cancelDateUtc: null, lastUpdateUtc: new Date(), dateEnteredUtc: new Date() },
productDataTranslation,
ctx
);
});
});
test("missing corporate location triggers skipRow", async () => {
const mod = await import("../product-data");
productDataTranslation = mod.productDataTranslation as unknown as AnyTranslation;
const ctx = makeContext({
catalogItemIds: new Set([1]),
corporateLocationIds: new Set(), // empty
});
expectSkipRow(() => {
translateRow(
{ ivProductRecId: 1, quantity: 1, catalogRecId: 1, ownerLevelRecId: 999, internalNote: null, shortDescription: null, sequenceNumber: null, unitPrice: 10, unitCost: 5, listPrice: 15, discountAmount: 0, recurringRevenue: 0, recurringCost: 0, qtyPicked: 0, qtyShipped: 0, cancelReason: null, quantityCancelled: null, billableFlag: true, taxableFlag: false, invoiceFlag: false, recurringFlag: false, poApprovedFlag: false, calcPriceFlag: false, calcCostFlag: false, cancelFlag: false, srServiceRecId: null, opportunityRecId: null, updatedBy: null, enteredBy: null, closedBy: null, cancelBy: null, dateClosedUtc: null, cancelDateUtc: null, lastUpdateUtc: new Date(), dateEnteredUtc: new Date() },
productDataTranslation,
ctx
);
});
});
test("numeric conversions handle null and invalid values", async () => {
const mod = await import("../product-data");
productDataTranslation = mod.productDataTranslation as unknown as AnyTranslation;
const ctx = makeContext({
catalogItemIds: new Set([1]),
corporateLocationIds: new Set([1]),
serviceTicketIds: new Set(),
opportunityIds: new Set(),
productCustomByIvProductId: new Map(),
});
const result = translateRow(
{ ivProductRecId: 1, quantity: null, catalogRecId: 1, ownerLevelRecId: 1, internalNote: null, shortDescription: null, sequenceNumber: null, unitPrice: null, unitCost: null, listPrice: null, discountAmount: null, recurringRevenue: null, recurringCost: null, qtyPicked: null, qtyShipped: null, cancelReason: null, quantityCancelled: null, billableFlag: true, taxableFlag: false, invoiceFlag: false, recurringFlag: false, poApprovedFlag: false, calcPriceFlag: false, calcCostFlag: false, cancelFlag: false, srServiceRecId: null, opportunityRecId: null, updatedBy: null, enteredBy: null, closedBy: null, cancelBy: null, dateClosedUtc: null, cancelDateUtc: null, lastUpdateUtc: new Date(), dateEnteredUtc: new Date() },
productDataTranslation,
ctx
);
expect(result.qty).toBe(1); // null quantity defaults to 1
expect(result.unitPrice).toBe(0);
expect(result.unitCost).toBe(0);
expect(result.listPrice).toBe(0);
expect(result.discount).toBe(0);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Service Ticket Priority Translator (Boolean fix)
// ═══════════════════════════════════════════════════════════════════════════
describe("serviceTicketPriorityTranslation", () => {
test("defaultFlag is coerced to boolean", async () => {
const mod = await import("../service-ticket-priority");
const translation = mod.serviceTicketPriorityTranslation as unknown as AnyTranslation;
const ctx = makeContext();
// Truthy value
const result1 = translateRow(
{ srUrgencyRecId: 1, description: "High", color: "#FF0000", defaultFlag: 1, createdBy: null, updatedBy: null, dateCreatedUtc: new Date(), lastUpdatedUtc: new Date() },
translation, ctx
);
expect(result1.defaultFlag).toBe(true);
// Falsy value
const result2 = translateRow(
{ srUrgencyRecId: 2, description: "Low", color: null, defaultFlag: 0, createdBy: null, updatedBy: null, dateCreatedUtc: new Date(), lastUpdatedUtc: new Date() },
translation, ctx
);
expect(result2.defaultFlag).toBe(false);
// Null value
const result3 = translateRow(
{ srUrgencyRecId: 3, description: "Med", color: null, defaultFlag: null, createdBy: null, updatedBy: null, dateCreatedUtc: new Date(), lastUpdatedUtc: new Date() },
translation, ctx
);
expect(result3.defaultFlag).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// SkipRowError type
// ═══════════════════════════════════════════════════════════════════════════
describe("SkipRowError", () => {
test("is distinct from generic Error", () => {
const err = new SkipRowError("test reason");
expect(err).toBeInstanceOf(SkipRowError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("SkipRowError");
expect(err.message).toBe("test reason");
expect(err.__brand).toBe("SkipRowError");
});
test("does not match SKIP_ROW: prefix check", () => {
const err = new SkipRowError("Something wrong");
// Old code would check: error.message.startsWith("SKIP_ROW:")
// New errors don't have that prefix
expect(err.message.startsWith("SKIP_ROW:")).toBe(false);
});
});
@@ -0,0 +1,23 @@
import { ProductCategory as CwProductCategory } from "../../generated/prisma/client";
import { CatalogCategory as ApiCatalogCategory } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const catalogCategoryTranslation: Translation<
CwProductCategory,
ApiCatalogCategory
> = {
values: [
{ from: "categoryRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Category"),
},
{ from: "description", to: "description" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
{ from: "dateEnteredUtc", to: "createdAt" },
],
};
@@ -0,0 +1,24 @@
import { ProductType as CwProductType } from "../../generated/prisma/client";
import { CatalogItemType as ApiCatalogItemType } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const catalogItemTypeTranslation: Translation<
CwProductType,
ApiCatalogItemType
> = {
values: [
{ from: "typeRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Type"),
},
{ from: "description", to: "description" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "defaultFlag", to: "defaultFlag" },
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
{ from: "dateEnteredUtc", to: "createdAt" },
],
};
+58
View File
@@ -0,0 +1,58 @@
import { ProductCatalog as CwProductCatalog } from "../../generated/prisma/client";
import { CatalogItem as ApiCatalogItem } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const catalogItemTranslation: Translation<
CwProductCatalog,
ApiCatalogItem
> = {
values: [
{ from: "catalogRecId", to: "id" },
{ from: "itemId", to: "identifier" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unnamed Item"),
},
{ from: "longDescription", to: "description" },
{ from: "longDescription", to: "customerDescription" },
{ from: "notes", to: "internalNotes" },
{
from: "subcategoryRecId",
to: "subcategoryId",
process: (value) => {
if (value == null) {
throw new Error(
"CatalogItem subcategoryId is required but subcategoryRecId is null"
);
}
return value;
},
},
{ from: "manufacturerRecId", to: "manufacturerId" },
{ from: "manufacturerPartNum", to: "partNumber" },
{ from: "vendorSku", to: "vendorSku" },
{ from: "vendorRecId", to: "vendorCwId" },
{
from: "listPrice",
to: "price",
process: (value) => Number(value),
},
{
from: "currentCost",
to: "cost",
process: (value) => (value == null ? 0 : Number(value)),
},
{ from: "inactiveFlag", to: "inactive" },
{ from: "taxableFlag", to: "salesTaxable" },
{
from: "minimumStock",
to: "onHand",
process: (value) => (value == null ? 0 : value),
},
{ from: "classId", to: "classId" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "cwLastUpdated" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,23 @@
import { Manufacturer as CwManufacturer } from "../../generated/prisma/client";
import { CatalogManufacturer as ApiCatalogManufacturer } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const catalogManufacturerTranslation: Translation<
CwManufacturer,
ApiCatalogManufacturer
> = {
values: [
{ from: "manufacturerRecId", to: "id" },
{ from: "manufacturerName", to: "name" },
{ from: "manufacturerName", to: "description" },
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
{ from: "dateEnteredUtc", to: "createdAt" },
],
};
@@ -0,0 +1,24 @@
import { ProductSubcategory as CwProductSubcategory } from "../../generated/prisma/client";
import { CatalogSubcategory as ApiCatalogSubcategory } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const catalogSubcategoryTranslation: Translation<
CwProductSubcategory,
ApiCatalogSubcategory
> = {
values: [
{ from: "subcategoryRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Subcategory"),
},
{ from: "description", to: "description" },
{ from: "categoryRecId", to: "categoryId" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
{ from: "dateEnteredUtc", to: "createdAt" },
],
};
@@ -0,0 +1,53 @@
import { CompanyAddress as CwCompanyAddress } from "../../generated/prisma/client";
import {
CompanyAddress as ApiCompanyAddress,
Country,
USState,
} from "../../../api/generated/prisma/client";
import { Translation } from "./types";
const toUSState = (value: string | null): USState | null => {
if (!value) return null;
const normalized = value.trim().toUpperCase();
if (normalized in USState) {
return normalized as USState;
}
return null;
};
const toCountry = (value: number | null): Country | null => {
if (value == null) return null;
return Country.US;
};
export const companyAddressTranslation: Translation<
CwCompanyAddress,
ApiCompanyAddress
> = {
values: [
{ from: "companyAddressRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Primary Site"),
},
{ from: "description", to: "description" },
{ from: "addressLine1", to: "addressLine1" },
{ from: "addressLine2", to: "addressLine2" },
{ from: "city", to: "city" },
{ from: "stateId", to: "state", process: toUSState },
{ from: "zip", to: "zipCode" },
{ from: "countryRecId", to: "country", process: toCountry },
{ from: "phoneNbr", to: "phone" },
{ from: "phoneNbrFax", to: "fax" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "defaultFlag", to: "defaultFlag" },
{ from: "defaultMailFlag", to: "defaultMailFlag" },
{ from: "defaultBillFlag", to: "defaultBillFlag" },
{ from: "defaultShipFlag", to: "defaultShipFlag" },
{ from: "updatedBy", to: "updatedById" },
{ from: "companyRecId", to: "companyId" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+18 -2
View File
@@ -2,6 +2,22 @@ import { Company as CwCompany } from "../../generated/prisma/client";
import { Company as ApiCompany } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
const companyTranslation: Translation<CwCompany, ApiCompany> = {
values: [{ from: "" }],
export const companyTranslation: Translation<CwCompany, ApiCompany> = {
values: [
{ from: "companyRecId", to: "id" },
{
from: "companyName",
to: "name",
process: (value) => (value ? value : "Unnamed Company"),
},
{ from: "phoneNbr", to: "phone" },
{ from: "websiteUrl", to: "website" },
{ from: "deleteFlag", to: "deleteFlag" },
{ from: "dateDeleted", to: "dateDeleted" },
{ from: "taxId", to: "taxId" },
{ from: "deletedBy", to: "deletedById" },
{ from: "enteredBy", to: "enteredById" },
{ from: "dateEntered", to: "createdAt" },
{ from: "lastUpdate", to: "updatedAt" },
],
};
+63
View File
@@ -0,0 +1,63 @@
import { Contact as CwContact } from "../../generated/prisma/client";
import {
Contact as ApiContact,
GenderType,
PhoneType,
} from "../../../api/generated/prisma/client";
import { Translation } from "./types";
const toGender = (value: string | null): GenderType | null => {
if (!value) return null;
const normalized = value.trim().toUpperCase();
if (normalized === "M") return GenderType.MALE;
if (normalized === "F") return GenderType.FEMALE;
return null;
};
const toPhoneType = (value: string | null): PhoneType | null => {
if (!value) return null;
const normalized = value.trim().toUpperCase();
if (normalized === "DIRECT") return PhoneType.DIRECT;
if (normalized === "MOBILE") return PhoneType.MOBILE;
if (normalized === "HOME") return PhoneType.HOME;
if (normalized === "COMPANY") return PhoneType.COMPANY;
if (normalized === "SITE") return PhoneType.SITE;
return null;
};
export const contactTranslation: Translation<CwContact, ApiContact> = {
values: [
{ from: "contactRecId", to: "id" },
{
from: "inactiveFlag",
to: "active",
process: (value) => !value,
},
{
from: "defaultFlag",
to: "default",
process: (value) => Boolean(value),
},
{
from: "firstName",
to: "firstName",
process: (value) => (value ? value : "Unknown"),
},
{
from: "lastName",
to: "lastName",
process: (value) => (value ? value : "Contact"),
},
{ from: "nickName", to: "nickname" },
{ from: "title", to: "title" },
{ from: "gender", to: "gender", process: toGender },
{ from: "dateBirth", to: "birthday" },
{ from: "defaultPhoneNbr", to: "phone" },
{ from: "defaultPhoneExtension", to: "phoneExtension" },
{ from: "defaultPhoneType", to: "phoneType", process: toPhoneType },
{ from: "companyAddressRecId", to: "companyAddressId" },
{ from: "companyRecId", to: "companyId" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+107
View File
@@ -0,0 +1,107 @@
/**
* Global context object passed to all translation process functions
* Contains lookup tables and computed values from external sources
*/
export interface TranslationContext {
// Set of API User.id values for FK validation
userIds: Set<string>;
// Set of API ServiceTicket.id values for FK validation
serviceTicketIds: Set<number>;
// Set of API Opportunity.id values for FK validation
opportunityIds: Set<number>;
// Set of API CatalogItem.id values for FK validation
catalogItemIds: Set<number>;
// Set of API CorporateLocation.id values for FK validation
corporateLocationIds: Set<number>;
// Set of API Company.id values for FK validation
companyIds: Set<number>;
// Set of API CompanyAddress.id values for FK validation
companyAddressIds: Set<number>;
// Set of API Contact.id values for FK validation
contactIds: Set<number>;
// Set of API OpportunityStage.id values for FK validation
opportunityStageIds: Set<number>;
// User lookups: CW memberRecId -> API User.id
usersByMemberRecId: Map<number, string>;
// User lookups: CW memberRecId -> API User.cwIdentifier
userIdentifiersByMemberRecId: Map<number, string>;
// User lookups: CW member identifier string -> API User.id
usersByIdentifier: Map<string, string>;
// Service ticket board lookups: API ServiceTicketBoard.id -> API ServiceTicketBoard.uid
serviceTicketBoardUidsById: Map<number, string>;
// Optional custom-field derived lookups keyed by CW SR_Service_RecID
billingTypeByTicketId: Map<number, string>;
billingInstructionsByTicketId: Map<number, string>;
// Opportunity/Product custom-field lookups loaded from CW reporting views.
opportunityNarrativeByOpportunityId: Map<number, string>;
productCustomByIvProductId: Map<
number,
{
procurementNotes: string | null;
productNarrative: string | null;
}
>;
// Required FK fallback for Opportunity.typeId
defaultOpportunityTypeId: number | null;
// Set of valid OpportunityStatus.id values for FK validation
opportunityStatusIds: Set<number>;
// Set of valid ScheduleStatus.id values for FK validation
scheduleStatusIds: Set<number>;
// Set of valid ScheduleType.id values for FK validation
scheduleTypeIds: Set<number>;
// Set of valid ScheduleSpan.id values for FK validation
scheduleSpanIds: Set<number>;
// Set of API TaxCode.id values for FK validation
taxCodeIds: Set<number>;
}
/**
* Create an empty context object with initialized maps
*/
export function createTranslationContext(): TranslationContext {
return {
userIds: new Set(),
serviceTicketIds: new Set(),
opportunityIds: new Set(),
catalogItemIds: new Set(),
corporateLocationIds: new Set(),
companyIds: new Set(),
companyAddressIds: new Set(),
contactIds: new Set(),
opportunityStageIds: new Set(),
usersByMemberRecId: new Map(),
userIdentifiersByMemberRecId: new Map(),
usersByIdentifier: new Map(),
serviceTicketBoardUidsById: new Map(),
billingTypeByTicketId: new Map(),
billingInstructionsByTicketId: new Map(),
opportunityNarrativeByOpportunityId: new Map(),
productCustomByIvProductId: new Map(),
defaultOpportunityTypeId: null,
opportunityStatusIds: new Set(),
scheduleStatusIds: new Set(),
scheduleTypeIds: new Set(),
scheduleSpanIds: new Set(),
taxCodeIds: new Set(),
};
}
@@ -0,0 +1,45 @@
import { OwnerLevel as CwOwnerLevel } from "../../generated/prisma/client";
import {
CorporateLocation as ApiCorporateLocation,
Country,
USState,
} from "../../../api/generated/prisma/client";
import { Translation } from "./types";
const toUSState = (value: string | null): USState | null => {
if (!value) return null;
const normalized = value.trim().toUpperCase();
if (normalized in USState) {
return normalized as USState;
}
return null;
};
const toCountry = (value: number | null): Country | null => {
if (value == null) return null;
return Country.US;
};
export const corporateLocationTranslation: Translation<
CwOwnerLevel,
ApiCorporateLocation
> = {
values: [
{ from: "ownerLevelRecId", to: "id" },
{
from: "ownerLevelName",
to: "name",
process: (value) => (value ? value : "Unknown Location"),
},
{ from: "description", to: "description" },
{ from: "updatedBy", to: "updatedById" },
{ from: "olAddressLine1", to: "addressLine1" },
{ from: "olAddressLine2", to: "addressLine2" },
{ from: "olCity", to: "city" },
{ from: "olStateId", to: "state", process: toUSState },
{ from: "olZip", to: "zipCode" },
{ from: "olCountryRecId", to: "country", process: toCountry },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+29
View File
@@ -0,0 +1,29 @@
import { Member as CwMember } from "../../generated/prisma/client";
import { CwMember as ApiCwMember } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const cwMemberTranslation: Translation<CwMember, ApiCwMember> = {
values: [
{ from: "memberRecId", to: "cwMemberId" },
{ from: "memberId", to: "identifier" },
{
from: "firstName",
to: "firstName",
process: (value) => (value ? value : "Unknown"),
},
{
from: "lastName",
to: "lastName",
process: (value) => (value ? value : "Member"),
},
{ from: "emailAddress", to: "officeEmail" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{
from: "dateHire",
to: "createdAt",
process: (value, _context, row) => value ?? row.lastUpdatedUtc,
},
{ from: "lastUpdatedUtc", to: "cwLastUpdated" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,20 @@
import { Department as CwDepartment } from "../../generated/prisma/client";
import { InternalDepartment as ApiInternalDepartment } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const internalDepartmentTranslation: Translation<
CwDepartment,
ApiInternalDepartment
> = {
values: [
{ from: "departmentRecId", to: "id" },
{
from: "departmentName",
to: "name",
process: (value) => (value ? value : "Unknown Department"),
},
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "updatedBy", to: "updatedById" },
{ from: "lastUpdateUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,32 @@
import { SoPipeline as CwSoPipeline } from "../../generated/prisma/client";
import { OpportunityStage as ApiOpportunityStage } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
type ApiOpportunityStageRecord = {
id: number;
name: string;
seqNbr?: number | null;
funnelColor?: string | null;
updatedById?: string | null;
createdAt: Date;
updatedAt: Date;
};
export const opportunityStageTranslation: Translation<
CwSoPipeline,
ApiOpportunityStageRecord
> = {
values: [
{ from: "soPipelineRecId", to: "id" },
{
from: "description",
to: "name",
process: (v: string | null) => v ?? "Unknown Stage",
},
{ from: "seqNbr", to: "seqNbr" },
{ from: "funnelColor", to: "funnelColor" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,26 @@
import { SoOppStatus as CwOpportunityStatus } from "../../generated/prisma/client";
import { OpportunityStatus as ApiOpportunityStatus } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const opportunityStatusTranslation: Translation<
CwOpportunityStatus,
ApiOpportunityStatus
> = {
values: [
{ from: "soOppStatusRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Status"),
},
{ from: "description", to: "description" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "defaultFlag", to: "defaultFlag" },
{ from: "wonFlag", to: "wonFlag" },
{ from: "lostFlag", to: "lostFlag" },
{ from: "closedFlag", to: "closeFlag" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "updatedBy", to: "updatedById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,22 @@
import { SoType as CwOpportunityType } from "../../generated/prisma/client";
import { OpportunityType as ApiOpportunityType } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const opportunityTypeTranslation: Translation<
CwOpportunityType,
ApiOpportunityType
> = {
values: [
{ from: "soTypeRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Type"),
},
{ from: "description", to: "description" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "updatedBy", to: "updatedById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+143
View File
@@ -0,0 +1,143 @@
import {
Opportunity as CwOpportunity,
OpportunityMember as CwOpportunityMember,
} from "../../generated/prisma/client";
import { OpportunityInterest } from "../../../api/generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
type ApiOpportunityRecord = {
id: number;
name: string;
notes?: string | null;
oppNarrative?: string | null;
typeId: number;
stageCwId?: number | null;
stageId?: number | null;
statusId?: number | null;
taxCodeId?: number | null;
interest?: OpportunityInterest | null;
probability: number;
source?: string | null;
primarySalesRepId?: string | null;
secondarySalesRepId?: string | null;
companyId?: number | null;
contactId?: number | null;
siteId?: number | null;
customerPO?: string | null;
expectedCloseDate?: Date | null;
pipelineChangeDate?: Date | null;
dateBecameLead?: Date | null;
closedDate?: Date | null;
closedFlag: boolean;
closedById?: string | null;
updatedBy: string;
eneteredBy: string;
createdAt: Date;
updatedAt: Date;
};
type CwOpportunityWithMembers = CwOpportunity & {
members?: Pick<
CwOpportunityMember,
"memberRecId" | "primarySalesFlag" | "secondarySalesFlag"
>[];
};
const toInterest = (value: number | null): OpportunityInterest | null => {
if (value == null) return null;
if (value <= 1) return OpportunityInterest.COLD;
if (value === 2) return OpportunityInterest.WARM;
return OpportunityInterest.HOT;
};
export const opportunityTranslation: Translation<
CwOpportunityWithMembers,
ApiOpportunityRecord,
TranslationContext
> = {
values: [
{ from: "opportunityRecId", to: "id" },
{ from: "opportunityName", to: "name" },
{ from: "notes", to: "notes" },
{
from: "opportunityRecId",
to: "oppNarrative",
process: (value, context) => {
if (value == null) return null;
return context.opportunityNarrativeByOpportunityId.get(value) ?? null;
},
},
{
from: "soTypeRecId",
to: "typeId",
process: (value, context) => {
if (value != null) return value;
if (context.defaultOpportunityTypeId != null)
return context.defaultOpportunityTypeId;
skipRow("Opportunity missing type and no default type available");
},
},
{ from: "soPipelineRecId", to: "stageId" },
{ from: "soOppStatusRecId", to: "statusId" },
{ from: "taxCodeRecId", to: "taxCodeId" },
{
from: "soInterestRecId",
to: "interest",
process: toInterest,
},
{
from: "probabilityToClose",
to: "probability",
process: (value) => (value == null ? 0 : value),
},
{ from: "source", to: "source" },
{
from: "opportunityRecId",
to: "primarySalesRepId",
process: (_value, context, row) => {
const primary = row.members?.find((member) => member.primarySalesFlag);
if (!primary) return null;
return (
context.userIdentifiersByMemberRecId.get(primary.memberRecId) ?? null
);
},
},
{
from: "opportunityRecId",
to: "secondarySalesRepId",
process: (_value, context, row) => {
const secondary = row.members?.find(
(member) => member.secondarySalesFlag
);
if (!secondary) return null;
return (
context.userIdentifiersByMemberRecId.get(secondary.memberRecId) ??
null
);
},
},
{ from: "companyRecId", to: "companyId" },
{ from: "contactRecId", to: "contactId" },
{ from: "companyAddressRecId", to: "siteId" },
{ from: "poNumber", to: "customerPO" },
{ from: "dateCloseExpected", to: "expectedCloseDate" },
{ from: "datePipelineChange", to: "pipelineChangeDate" },
{ from: "dateBecameLead", to: "dateBecameLead" },
{ from: "dateClosed", to: "closedDate" },
{ from: "oldCloseFlag", to: "closedFlag" },
{ from: "closedBy", to: "closedById" },
{
from: "updatedBy",
to: "updatedBy",
process: (value) => (value ? value : "system"),
},
{
from: "enteredBy",
to: "eneteredBy",
process: (value) => (value ? value : "system"),
},
{ from: "dateBecameLeadUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+218
View File
@@ -0,0 +1,218 @@
import { IV_Product as CwIVProduct } from "../../generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
type ApiProductDataRecord = {
id: number;
qty: number;
internalNote?: string | null;
shortDescription?: string | null;
description?: string | null;
sequenceNumber?: number | null;
procurementNotes?: string | null;
productNarrative?: string | null;
unitPrice: number;
unitCost: number;
listPrice: number;
discount: number;
recurringRevenue: number;
recurringCost: number;
qtyPicked: number;
qtyShipped: number;
cancelReason?: string | null;
cancelQty?: number | null;
billableFlag: boolean;
taxableFlag: boolean;
invoiceFlag: boolean;
recurringFlag: boolean;
poApprovedFlag: boolean;
calcPriceFlag: boolean;
calcCostFlag: boolean;
cancelFlag: boolean;
catalogItemId: number;
corporateLocationId: number;
serviceTicketId?: number | null;
opportunityId?: number | null;
updatedById?: string | null;
createdById?: string | null;
closedById?: string | null;
cancelById: string;
closedAt?: Date | null;
cancelledAt?: Date | null;
createdAt: Date;
updatedAt: Date;
};
const toInt = (value: unknown, fallback = 0): number => {
if (value == null) return fallback;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.trunc(numeric);
};
const toNumber = (value: unknown, fallback = 0): number => {
if (value == null) return fallback;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
};
export const productDataTranslation: Translation<
CwIVProduct,
ApiProductDataRecord,
TranslationContext
> = {
values: [
{ from: "ivProductRecId", to: "id" },
{
from: "quantity",
to: "qty",
process: (value) => toNumber(value, 1),
},
{ from: "internalNote", to: "internalNote" },
{ from: "shortDescription", to: "shortDescription" },
{ from: "description", to: "description" },
{
from: "ivProductRecId",
to: "procurementNotes",
process: (value, context) => {
const custom = context.productCustomByIvProductId.get(value);
return custom?.procurementNotes ?? null;
},
},
{
from: "ivProductRecId",
to: "productNarrative",
process: (value, context) => {
const custom = context.productCustomByIvProductId.get(value);
return custom?.productNarrative ?? null;
},
},
{
from: "sequenceNumber",
to: "sequenceNumber",
process: (value) => (value == null ? null : toInt(value, 0)),
},
{
from: "unitPrice",
to: "unitPrice",
process: (value) => toNumber(value, 0),
},
{
from: "unitCost",
to: "unitCost",
process: (value) => toNumber(value, 0),
},
{
from: "listPrice",
to: "listPrice",
process: (value) => toNumber(value, 0),
},
{
from: "discountAmount",
to: "discount",
process: (value) => toNumber(value, 0),
},
{
from: "recurringRevenue",
to: "recurringRevenue",
process: (value) => toNumber(value, 0),
},
{
from: "recurringCost",
to: "recurringCost",
process: (value) => toNumber(value, 0),
},
{
from: "qtyPicked",
to: "qtyPicked",
process: (value) => toInt(value, 0),
},
{
from: "qtyShipped",
to: "qtyShipped",
process: (value) => toInt(value, 0),
},
{ from: "cancelReason", to: "cancelReason" },
{
from: "quantityCancelled",
to: "cancelQty",
process: (value) => (value == null ? null : toNumber(value, 0)),
},
{ from: "billableFlag", to: "billableFlag" },
{ from: "taxableFlag", to: "taxableFlag" },
{ from: "invoiceFlag", to: "invoiceFlag" },
{ from: "recurringFlag", to: "recurringFlag" },
{ from: "poApprovedFlag", to: "poApprovedFlag" },
{ from: "calcPriceFlag", to: "calcPriceFlag" },
{ from: "calcCostFlag", to: "calcCostFlag" },
{ from: "cancelFlag", to: "cancelFlag" },
{
from: "catalogRecId",
to: "catalogItemId",
process: (value, context) => {
if (!context.catalogItemIds.has(value)) {
skipRow(
`ProductData catalog item missing for catalogRecId ${value}`
);
}
return value;
},
},
{
from: "ownerLevelRecId",
to: "corporateLocationId",
process: (value, context) => {
if (!context.corporateLocationIds.has(value)) {
skipRow(
`ProductData corporate location missing for ownerLevelRecId ${value}`
);
}
return value;
},
},
{
from: "srServiceRecId",
to: "serviceTicketId",
process: (value, context) => {
if (value == null) return null;
return context.serviceTicketIds.has(value) ? value : null;
},
},
{
from: "opportunityRecId",
to: "opportunityId",
process: (value, context) => {
if (value == null) return null;
return context.opportunityIds.has(value) ? value : null;
},
},
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "closedBy", to: "closedById" },
{
from: "cancelBy",
to: "cancelById",
process: (value, context) => {
if (value == null) return "system";
return context.usersByMemberRecId.get(value) ?? `cw-member:${value}`;
},
},
{ from: "dateClosedUtc", to: "closedAt" },
{ from: "cancelDateUtc", to: "cancelledAt" },
{
from: "dateEntered",
to: "createdAt",
process: (value) => value ?? new Date(0),
},
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => value ?? new Date(0),
},
{
from: "lastUpdatedUTC",
to: "updatedAt",
process: (value) => value ?? new Date(0),
},
],
};
@@ -0,0 +1,30 @@
import { ProductInventory as CwProductInventory } from "../../generated/prisma/client";
import { ProductInventory as ApiProductInventory } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const productInventoryTranslation: Translation<
CwProductInventory,
ApiProductInventory
> = {
values: [
{ from: "inventoryRecId", to: "id" },
{
from: "qtyOnHand",
to: "qtyOnHand",
process: (value) => (value == null ? 0 : Number(value)),
},
{
from: "lastUpdate",
to: "createdAt",
process: (value) => (value ? value : new Date(0)),
},
{ from: "warehouseBinRecId", to: "warehouseBinId" },
{ from: "catalogRecId", to: "itemId" },
{ from: "updatedBy", to: "updatedById" },
{
from: "lastUpdate",
to: "updatedAt",
process: (value) => (value ? value : new Date(0)),
},
],
};
+14
View File
@@ -0,0 +1,14 @@
import { ScheduleSpan as CwScheduleSpan } from "../../generated/prisma/client";
import { ScheduleSpan as ApiScheduleSpan } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const scheduleSpanTranslation: Translation<
CwScheduleSpan,
ApiScheduleSpan
> = {
values: [
{ from: "scheduleSpanRecId", to: "id" },
{ from: "scheduleSpanId", to: "scheduleSpanId" },
{ from: "spanDesc", to: "spanDesc" },
],
};
@@ -0,0 +1,33 @@
import { ScheduleStatus as CwScheduleStatus } from "../../generated/prisma/client";
import { ScheduleStatus as ApiScheduleStatus } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const scheduleStatusTranslation: Translation<
CwScheduleStatus,
ApiScheduleStatus
> = {
values: [
{ from: "scheduleStatusRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Status"),
},
{ from: "description", to: "description" },
{ from: "color", to: "color" },
{
from: "softFlag",
to: "softFlag",
process: (value) => Boolean(value),
},
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdateUtc", to: "updatedAt" },
],
};
+36
View File
@@ -0,0 +1,36 @@
import { ScheduleType as CwScheduleType } from "../../generated/prisma/client";
import { ScheduleType as ApiScheduleType } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const scheduleTypeTranslation: Translation<
CwScheduleType,
ApiScheduleType
> = {
values: [
{ from: "scheduleTypeRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Type"),
},
{ from: "description", to: "description" },
{ from: "displayColor", to: "displayColor" },
{ from: "tableReference", to: "tableReference" },
{ from: "moduleId", to: "moduleId" },
{ from: "scheduleTypeId", to: "scheduleTypeId" },
{
from: "systemFlag",
to: "systemFlag",
process: (value) => Boolean(value),
},
{
from: "displayFlag",
to: "displayFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdateUtc", to: "updatedAt" },
],
};
+114
View File
@@ -0,0 +1,114 @@
import { Schedule as CwSchedule } from "../../generated/prisma/client";
import { Translation } from "./types";
type ApiScheduleRecord = {
id: number;
name: string;
description: string | null;
closedFlag: boolean;
reminderFlag: boolean;
allDayFlag: boolean;
acknowledgementFlag: boolean;
meetingFlag: boolean;
recurringFlag: boolean;
billableFlag: boolean;
acknowledgedById: string | null;
acknowledgedAt: Date | null;
startDate: Date | null;
endDate: Date | null;
hoursScheduled: number | null;
duration: number | null;
hoursPerDay: number | null;
reminderMinutes: number | null;
statusId: number | null;
typeId: number | null;
scheduleSpanId: number | null;
memberId: string | null;
updatedById: string | null;
createdById: string | null;
closedById: string | null;
closedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export const scheduleTranslation: Translation<CwSchedule, ApiScheduleRecord> = {
values: [
{ from: "scheduleRecId", to: "id" },
{
from: "scheduleDesc",
to: "name",
process: (value) => (value ? value : ""),
},
{ from: "scheduleDesc", to: "description" },
{
from: "closeFlag",
to: "closedFlag",
process: (value) => Boolean(value),
},
{
from: "reminderFlag",
to: "reminderFlag",
process: (value) => Boolean(value),
},
{
from: "allDayFlag",
to: "allDayFlag",
process: (value) => Boolean(value),
},
{
from: "ackFlag",
to: "acknowledgementFlag",
process: (value) => Boolean(value),
},
{
from: "meetingFlag",
to: "meetingFlag",
process: (value) => Boolean(value),
},
{
from: "recurringFlag",
to: "recurringFlag",
process: (value) => Boolean(value),
},
{
from: "billableFlag",
to: "billableFlag",
process: (value) => Boolean(value),
},
{ from: "acknowledgedBy", to: "acknowledgedById" },
{ from: "ackDateUtc", to: "acknowledgedAt" },
{ from: "dateTimeStartUtc", to: "startDate" },
{ from: "dateTimeEndUtc", to: "endDate" },
{
from: "hoursSched",
to: "hoursScheduled",
process: (value) => (value != null ? Number(value) : null),
},
{ from: "duration", to: "duration" },
{
from: "hoursPerDay",
to: "hoursPerDay",
process: (value) => (value != null ? Number(value) : null),
},
{ from: "reminderMinutes", to: "reminderMinutes" },
{ from: "scheduleStatusRecId", to: "statusId" },
{ from: "scheduleTypeRecId", to: "typeId" },
{ from: "scheduleSpanRecId", to: "scheduleSpanId" },
{ from: "memberId", to: "memberId" },
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "closedBy", to: "closedById" },
{ from: "closeDateUtc", to: "closedAt" },
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
{
from: "lastUpdateUtc",
to: "updatedAt",
process: (value) => (value as Date | null) ?? new Date(0),
},
],
};
@@ -0,0 +1,85 @@
import { SrBoard as CwServiceTicketBoard } from "../../generated/prisma/client";
import { ServiceTicketBoard as ApiServiceTicketBoard } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketBoardTranslation: Translation<
CwServiceTicketBoard,
ApiServiceTicketBoard
> = {
values: [
{ from: "srBoardRecId", to: "id" },
{
from: "boardName",
to: "name",
process: (value) => (value ? value : "Unknown Board"),
},
{
from: "timeBillableFlag",
to: "timeBillableFlag",
process: (value) => Boolean(value),
},
{
from: "expBillableFlag",
to: "expenseBillableFlag",
process: (value) => Boolean(value),
},
{
from: "prodBillableFlag",
to: "productBillableFlag",
process: (value) => Boolean(value),
},
{
from: "timeInvoiceFlag",
to: "timeInvoiceableFlag",
process: (value) => Boolean(value),
},
{
from: "expInvoiceFlag",
to: "expenseInvoiceableFlag",
process: (value) => Boolean(value),
},
{
from: "prodInvoiceFlag",
to: "productInvoiceableFlag",
process: (value) => Boolean(value),
},
{
from: "autoAssignNewFlag",
to: "autoAssignNewFlag",
process: (value) => Boolean(value),
},
{
from: "autoAssignEcFlag",
to: "autoAssignEmailCreatedFlag",
process: (value) => Boolean(value),
},
{
from: "autoAssignPortalFlag",
to: "autoAssignPortalCreatedFlag",
process: (value) => Boolean(value),
},
{ from: "projectFlag", to: "projectFlag" },
{
from: "lockDescFlag",
to: "lockDescriptionFlag",
process: (value) => Boolean(value),
},
{
from: "emailContactFlag",
to: "emailContactFlag",
process: (value) => Boolean(value),
},
{
from: "emailResourceFlag",
to: "emailResourceFlag",
process: (value) => Boolean(value),
},
{ from: "resolutionSort", to: "resolutionSortOrder" },
{ from: "internalAnalysisSort", to: "internalAnalysisSortOrder" },
{ from: "ownerLevelRecId", to: "locationId" },
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,27 @@
import { SrImpact as CwServiceTicketImpact } from "../../generated/prisma/client";
import { ServiceTicketImpact as ApiServiceTicketImpact } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketImpactTranslation: Translation<
CwServiceTicketImpact,
ApiServiceTicketImpact
> = {
values: [
{ from: "srImpactRecId", to: "id" },
{
from: "impactName",
to: "name",
process: (value) => (value ? value : "Unknown Impact"),
},
{ from: "impactDesc", to: "description" },
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,23 @@
import { SrLocation as CwServiceTicketLocation } from "../../generated/prisma/client";
import { ServiceTicketLocation as ApiServiceTicketLocation } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketLocationTranslation: Translation<
CwServiceTicketLocation,
ApiServiceTicketLocation
> = {
values: [
{ from: "srLocationRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Location"),
},
{ from: "description", to: "description" },
{ from: "defaultFlag", to: "defaultFlag" },
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,74 @@
import { TicketNote as CwTicketNote } from "../../generated/prisma/client";
import { ServiceTicketNote as ApiServiceTicketNote } from "../../../api/generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
export const serviceTicketNoteTranslation: Translation<
CwTicketNote,
ApiServiceTicketNote,
TranslationContext
> = {
values: [
{ from: "ticketNoteRecId", to: "id" },
{
from: "srServiceRecId",
to: "serviceTicketId",
process: (value: number | null, context: TranslationContext) => {
if (!value) {
skipRow("ServiceTicketNote missing srServiceRecId");
}
if (!context.serviceTicketIds.has(value)) {
skipRow(
`ServiceTicketNote parent ticket missing: ${value}`
);
}
return value;
},
},
{
from: "notesMarkdown",
to: "notesMd",
process: (value: string | null) => value || "",
},
{
from: "notes",
to: "notes",
process: (value: string | null) => value || "",
},
{
from: "memberRecId",
to: "authorId",
process: (value: number | null, context: TranslationContext) => {
if (!value) {
skipRow("ServiceTicketNote missing memberRecId");
}
const userId = context.usersByMemberRecId.get(value);
if (!userId) {
skipRow(
`Cannot find user mapping for memberRecId: ${value}`
);
}
return userId;
},
},
{ from: "problemFlag", to: "problemFlag" },
{ from: "resolutionFlag", to: "resolutionFlag" },
{ from: "internalAnalysisFlag", to: "internalAnalysisFlag" },
{ from: "internalMemberFlag", to: "internalMemberFlag" },
{ from: "mergedFlag", to: "mergedFlag" },
{ from: "bundledFlag", to: "bundledFlag" },
{ from: "createdByParentFlag", to: "createdByParentFlag" },
{
from: "dateCreatedUtc",
to: "createdAt",
process: (value: Date | null) => value || null,
},
{
from: "lastUpdatedUtc",
to: "updatedAt",
process: (value: Date | null) => value || null,
},
],
};
@@ -0,0 +1,28 @@
import { SrUrgency as CwServiceTicketPriority } from "../../generated/prisma/client";
import { ServiceTicketPriority as ApiServiceTicketPriority } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketPriorityTranslation: Translation<
CwServiceTicketPriority,
ApiServiceTicketPriority
> = {
values: [
{ from: "srUrgencyRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Priority"),
},
{ from: "description", to: "description" },
{ from: "color", to: "color" },
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{ from: "createdBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateCreatedUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,27 @@
import { SrSeverity as CwServiceTicketSeverity } from "../../generated/prisma/client";
import { ServiceTicketSeverity as ApiServiceTicketSeverity } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketSeverityTranslation: Translation<
CwServiceTicketSeverity,
ApiServiceTicketSeverity
> = {
values: [
{ from: "srSeverityRecId", to: "id" },
{
from: "severityName",
to: "name",
process: (value) => (value ? value : "Unknown Severity"),
},
{ from: "severityDesc", to: "description" },
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,23 @@
import { SrSource as CwServiceTicketSource } from "../../generated/prisma/client";
import { ServiceTicketSource as ApiServiceTicketSource } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketSourceTranslation: Translation<
CwServiceTicketSource,
ApiServiceTicketSource
> = {
values: [
{ from: "srSourceRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Source"),
},
{ from: "description", to: "description" },
{ from: "defaultFlag", to: "defaultFlag" },
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
@@ -0,0 +1,23 @@
import { SrType as CwServiceTicketType } from "../../generated/prisma/client";
import { ServiceTicketType as ApiServiceTicketType } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const serviceTicketTypeTranslation: Translation<
CwServiceTicketType,
ApiServiceTicketType
> = {
values: [
{ from: "srTypeRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unknown Type"),
},
{ from: "description", to: "description" },
{ from: "inactiveFlag", to: "inactiveFlag" },
{ from: "enteredBy", to: "createdById" },
{ from: "updatedBy", to: "updatedById" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+197
View File
@@ -0,0 +1,197 @@
import { SrService as CwSrService } from "../../generated/prisma/client";
import { ServiceTicket as ApiServiceTicket } from "../../../api/generated/prisma/client";
import { Translation, skipRow } from "./types";
import { TranslationContext } from "./context";
const toBillingMethod = (
value: string | null | undefined
): "ACTUAL_RATES" | "FIXED_FEE" | "NOT_TO_EXCEED" | "OVERRIDE_RATE" => {
if (!value) return "ACTUAL_RATES";
const normalized = value.trim().toUpperCase();
if (normalized === "A") return "ACTUAL_RATES";
if (normalized === "F") return "FIXED_FEE";
if (normalized === "N") return "NOT_TO_EXCEED";
if (normalized === "O") return "OVERRIDE_RATE";
// Keyword fallback for unexpected legacy values.
if (normalized.includes("FIX")) return "FIXED_FEE";
if (normalized.includes("EXCEED")) return "NOT_TO_EXCEED";
if (normalized.includes("OVERRIDE")) return "OVERRIDE_RATE";
return "ACTUAL_RATES";
};
const toBillingType = (value: string | undefined): "STANDARD" | "PROJECT" => {
if (!value) return "STANDARD";
const normalized = value.trim().toUpperCase();
if (normalized.includes("PROJECT")) return "PROJECT";
return "STANDARD";
};
const resolveUserId = (
value: string | null | undefined,
context: TranslationContext
): string | null => {
if (!value) return null;
const byIdentifier = context.usersByIdentifier.get(value);
if (byIdentifier) return byIdentifier;
const asRecId = Number.parseInt(value, 10);
if (!Number.isNaN(asRecId)) {
return context.usersByMemberRecId.get(asRecId) ?? null;
}
return null;
};
export const serviceTicketTranslation: Translation<
CwSrService,
ApiServiceTicket,
TranslationContext
> = {
values: [
{ from: "srServiceRecId", to: "id" },
{
from: "summary",
to: "summary",
process: (value) => {
if (!value || value.trim() === "") {
skipRow(
"ServiceTicket summary is required and cannot be empty"
);
}
return value;
},
},
{
from: "srLocationRecId",
to: "locationId",
process: (value) => {
if (value == null) {
skipRow("ServiceTicket locationId missing");
}
return value;
},
},
{
from: "srSourceRecId",
to: "sourceId",
process: (value) => {
if (value == null) {
skipRow("ServiceTicket sourceId missing");
}
return value;
},
},
{
from: "srUrgencyRecId",
to: "priorityId",
},
{
from: "srSeverityRecId",
to: "severityId",
process: (value) => {
if (value == null) {
skipRow("ServiceTicket severityId missing");
}
return value;
},
},
{
from: "srImpactRecId",
to: "impactId",
process: (value) => {
if (value == null) {
skipRow("ServiceTicket impactId missing");
}
return value;
},
},
{
from: "srBoardRecId",
to: "serviceTicketBoardId",
process: (value) => (value == null ? null : value),
},
{ from: "companyRecId", to: "companyId" },
{ from: "contactRecId", to: "contactId" },
{ from: "companyAddressRecId", to: "companyAddressId" },
{ from: "billingCompanyRecId", to: "billingCompanyId" },
{ from: "billingAddressRecId", to: "billingAddressId" },
{
from: "ticketOwnerRecId",
to: "ticketOwnerId",
process: (value, context) => {
if (!value) return null;
return context.usersByMemberRecId.get(value) || null;
},
},
{
from: "enteredBy",
to: "createdById",
process: (value, context) => {
return resolveUserId(value, context);
},
},
{
from: "updatedBy",
to: "updatedById",
process: (value, context) => {
return resolveUserId(value, context);
},
},
{
from: "closedBy",
to: "closedById",
process: (value, context) => {
return resolveUserId(value, context);
},
},
{
from: "dateEnteredUtc",
to: "createdAt",
process: (value) => value || null,
},
{
from: "lastUpdatedUtc",
to: "updatedAt",
process: (value) => value || null,
},
{ from: "dateClosedUtc", to: "closedAt" },
{
from: "isClosedFlag",
to: "closedFlag",
},
{
from: "billMethod",
to: "billingMethod",
process: (value, context) => {
return toBillingMethod(value);
},
},
{
from: "srServiceRecId",
to: "billingType",
process: (value, context) => {
return toBillingType(context.billingTypeByTicketId.get(value) ?? undefined);
},
},
{
from: "srServiceRecId",
to: "billingInstructions",
process: (value, context) => {
return context.billingInstructionsByTicketId.get(value) ?? null;
},
},
{ from: "poNumber", to: "poNumber" },
{
from: "billingAmount",
to: "billingAmount",
process: (value) => (value == null ? 0 : Number(value)),
},
{ from: "rejectedFlag", to: "rejectedFlag" },
{ from: "publishFlag", to: "publishFlag" },
{ from: "redFlag", to: "redFlag" },
],
};
+47
View File
@@ -0,0 +1,47 @@
import {
TaxCode as CwTaxCode,
TaxCodeLevel as CwTaxCodeLevel,
} from "../../generated/prisma/client";
import { TaxCode as ApiTaxCode } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
import { skipRow } from "./types";
type CwTaxCodeWithLevels = CwTaxCode & {
levels: CwTaxCodeLevel[];
};
export const taxCodeTranslation: Translation<
CwTaxCodeWithLevels,
ApiTaxCode
> = {
values: [
{ from: "taxCodeRecId", to: "id" },
{
from: "taxCodeId",
to: "code",
process: (value) => value ?? skipRow("taxCodeId is null"),
},
{
from: "codeCaption",
to: "codeCaption",
process: (value, _, row) =>
value ?? (row.taxCodeId as string | null) ?? "",
},
{ from: "description", to: "description" },
{
from: "taxCodeRecId",
to: "rate",
process: (_, __, row) => {
const level = (row as unknown as CwTaxCodeWithLevels).levels.find(
(l) => l.taxXref !== null
);
return level ? Number(level.taxRate) : null;
},
},
{ from: "defaultFlag", to: "defaultFlag" },
{ from: "enteredBy", to: "createdBy" },
{ from: "updatedBy", to: "updatedBy" },
{ from: "dateEnteredUtc", to: "createdAt" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+48 -10
View File
@@ -1,18 +1,56 @@
/**
* kF - keys From
* kT - keys To
* kC - extra context data available to process functions
*/
export type TranslationEntry<kF, kT> = {
export type TranslationEntry<kF, kT, kC = unknown> = {
from: keyof kF;
to: keyof kT;
} & (kF extends kT
? {
process?: (value: kF[keyof kF]) => kT[keyof kT];
}
: {
process: (value: kF[keyof kF]) => kT[keyof kT];
});
} & {
[kFrom in keyof kF]: {
[kTo in keyof kT]: {
from: kFrom;
to: kTo;
} & (kF[kFrom] extends kT[kTo]
? {
process?: (value: kF[kFrom], context: kC, row: kF) => kT[kTo];
}
: {
process: (value: kF[kFrom], context: kC, row: kF) => kT[kTo];
});
}[keyof kT];
}[keyof kF];
export interface Translation<kF, kT> {
values: TranslationEntry<kF, kT>[];
export interface Translation<kF, kT, kC = unknown> {
values: TranslationEntry<kF, kT, kC>[];
}
/**
* Represents the outcome of translating a single row.
* - ok: true → translation succeeded, `value` contains the translated row
* - ok: false → row should be skipped, `reason` explains why
*/
export type TranslationResult<T> =
| { ok: true; value: T }
| { ok: false; reason: string };
/**
* Sentinel class thrown by `skipRow()` inside process functions.
* Caught by `translateRow()` and converted to a `TranslationResult`.
* This avoids string-prefix matching on generic Error messages.
*/
export class SkipRowError extends Error {
readonly __brand = "SkipRowError" as const;
constructor(reason: string) {
super(reason);
this.name = "SkipRowError";
}
}
/**
* Convenience helper for process functions to signal that a row
* should be skipped. Preferred over `throw new Error("SKIP_ROW: …")`.
*/
export function skipRow(reason: string): never {
throw new SkipRowError(reason);
}
+76
View File
@@ -0,0 +1,76 @@
import { Member as CwMember } from "../../generated/prisma/client";
import { User as ApiUser } from "../../../api/generated/prisma/client";
import { Translation, skipRow } from "./types";
const isValidEmail = (value: string | null): boolean => {
const normalized = value?.trim().toLowerCase();
return !!normalized && normalized.includes("@");
};
const isFullMember = (row: CwMember): boolean =>
row.memberClass === "F" && isValidEmail(row.emailAddress);
/**
* For full members, use their real email.
* For hidden members (non-F or missing valid email), generate a stable
* placeholder that satisfies the unique non-null login/email constraints.
*/
const resolveEmail = (row: CwMember): string => {
if (isFullMember(row)) {
return row.emailAddress!.trim().toLowerCase();
}
return `${row.memberId}@cw.local`;
};
export const userTranslation: Translation<CwMember, ApiUser> = {
values: [
{
from: "memberId",
to: "login",
// Use memberId@cw.local as a stable, unique login for all synced members.
// The real email goes into `email`; Microsoft OAuth overwrites `login` with
// the UPN on first login so this value is only a placeholder.
process: (value) => `${value}@cw.local`,
},
{
from: "firstName",
to: "firstName",
process: (value) => (value && value.trim() ? value.trim() : "Unknown"),
},
{
from: "lastName",
to: "lastName",
process: (value) => (value && value.trim() ? value.trim() : "Unknown"),
},
{
from: "emailAddress",
to: "email",
process: (_value, _context, row) => resolveEmail(row),
},
{ from: "memberRecId", to: "cwMemberId" },
{
from: "memberId",
to: "cwIdentifier",
process: (value) => {
const normalized = value?.trim();
return normalized ? normalized : null;
},
},
{
from: "inactiveFlag",
to: "active",
process: (value) => !value,
},
{
from: "memberClass",
to: "hidden",
process: (_value, _context, row) => !isFullMember(row),
},
{
from: "dateHire",
to: "createdAt",
process: (value, _context, row) => value ?? row.lastUpdatedUtc,
},
{ from: "lastUpdatedUtc", to: "updatedAt" },
],
};
+42
View File
@@ -0,0 +1,42 @@
import { WarehouseBin as CwWarehouseBin } from "../../generated/prisma/client";
import { WarehouseBin as ApiWarehouseBin } from "../../../api/generated/prisma/client";
import { Translation } from "./types";
export const warehouseBinTranslation: Translation<
CwWarehouseBin,
ApiWarehouseBin
> = {
values: [
{ from: "warehouseBinRecId", to: "id" },
{
from: "description",
to: "name",
process: (value) => (value ? value : "Unlabeled Bin"),
},
{ from: "description", to: "description" },
{
from: "minQuantity",
to: "minQuantity",
process: (value) => (value == null ? 0 : Number(value)),
},
{
from: "maxQuantity",
to: "maxQuantity",
process: (value) => (value == null ? 0 : Number(value)),
},
{
from: "inactiveFlag",
to: "inactiveFlag",
process: (value) => Boolean(value),
},
{
from: "defaultFlag",
to: "defaultFlag",
process: (value) => Boolean(value),
},
{ from: "updatedBy", to: "updatedById" },
{ from: "enteredBy", to: "createdById" },
{ from: "lastUpdatedUtc", to: "updatedAt" },
{ from: "dateEnteredUtc", to: "createdAt" },
],
};