all the haul
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* One-time backfill: copies IV_Class_ID (classId) from CW MSSQL ProductCatalog
|
||||
* to the API PostgreSQL CatalogItem table.
|
||||
*
|
||||
* Run with: bun backfill-classid.ts
|
||||
*/
|
||||
|
||||
import { PrismaMssql } from "@prisma/adapter-mssql";
|
||||
import { PrismaClient as CwPrismaClient } from "./generated/prisma/client";
|
||||
import pg from "/root/projects/optima/node_modules/.bun/pg@8.20.0+52bd52a0bccfa6a2/node_modules/pg/lib/index.js";
|
||||
|
||||
// In dalpuri's .env, DATABASE_URL is the CW MSSQL connection string.
|
||||
const CW_DATABASE_URL = process.env.DATABASE_URL!;
|
||||
|
||||
// The API PostgreSQL URL — hardcoded to avoid env var ambiguity
|
||||
const API_DATABASE_URL = "postgresql://optima:123web123@localhost:5432/optima";
|
||||
|
||||
const cwPrisma = new CwPrismaClient({ adapter: new PrismaMssql(CW_DATABASE_URL) });
|
||||
const pgPool = new pg.Pool({ connectionString: API_DATABASE_URL });
|
||||
|
||||
async function main() {
|
||||
console.log("[backfill-classid] Fetching ProductCatalog classId from CW MSSQL...");
|
||||
const cwItems = await cwPrisma.productCatalog.findMany({
|
||||
select: { catalogRecId: true, classId: true },
|
||||
});
|
||||
console.log(`[backfill-classid] Fetched ${cwItems.length} CW catalog items`);
|
||||
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Batch updates in groups of 500
|
||||
const BATCH_SIZE = 500;
|
||||
for (let i = 0; i < cwItems.length; i += BATCH_SIZE) {
|
||||
const batch = cwItems.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(
|
||||
batch.map(async (item) => {
|
||||
try {
|
||||
const result = await pgPool.query(
|
||||
'UPDATE "CatalogItem" SET "classId" = $1 WHERE "id" = $2',
|
||||
[item.classId, item.catalogRecId]
|
||||
);
|
||||
if (result.rowCount && result.rowCount > 0) {
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`[backfill-classid] Failed for id=${item.catalogRecId}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
console.log(`[backfill-classid] Progress: ${Math.min(i + BATCH_SIZE, cwItems.length)} / ${cwItems.length}`);
|
||||
}
|
||||
|
||||
console.log(`[backfill-classid] Done. Updated: ${updated}, Skipped (not in API DB): ${skipped}, Failed: ${failed}`);
|
||||
|
||||
// Verify
|
||||
const verifyRes = await pgPool.query<{nonNull: string, total: string}>(
|
||||
'SELECT COUNT(*) FILTER (WHERE "classId" IS NOT NULL) as "nonNull", COUNT(*) as total FROM "CatalogItem"'
|
||||
);
|
||||
const { nonNull, total } = verifyRes.rows[0];
|
||||
console.log(`[backfill-classid] Verification: ${nonNull} / ${total} catalog items now have classId`);
|
||||
|
||||
// Breakdown
|
||||
const breakdownRes = await pgPool.query<{classId: string | null, count: string}>(
|
||||
'SELECT "classId", COUNT(*) as count FROM "CatalogItem" GROUP BY "classId" ORDER BY "classId"'
|
||||
);
|
||||
for (const row of breakdownRes.rows) {
|
||||
const label = row.classId === 'S' ? 'Service/Labor' : row.classId === 'I' ? 'Inventory' : row.classId === 'N' ? 'Non-inventory' : 'null';
|
||||
console.log(` classId=${row.classId ?? 'null'} (${label}): ${row.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[backfill-classid] Fatal error:", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await Promise.all([cwPrisma.$disconnect(), pgPool.end()]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -2,20 +2,27 @@
|
||||
"name": "dalpuri",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
||||
"db:generate": "bunx prisma generate",
|
||||
"export:products": "bun run src/export-products.ts"
|
||||
"export:products": "bun run src/export-products.ts",
|
||||
"sync:cw-to-api": "bun run src/sync.ts",
|
||||
"sync:table": "bun run src/sync-table-updates.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-mssql": "^7.5.0",
|
||||
"@prisma/adapter-pg": "^7.5.0",
|
||||
"@prisma/client": "^7.5.0",
|
||||
"prisma": "^7.5.0",
|
||||
"socket.io": "^4.8.3"
|
||||
|
||||
+1200
-116
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
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";
|
||||
|
||||
// ─── Test‑only 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", () => {
|
||||
// Lazy‑load to sidestep top‑level 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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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)),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PrismaMssql } from "@prisma/adapter-mssql";
|
||||
import { PrismaClient } from "./generated/prisma/client";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is not set.");
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaMssql(connectionString),
|
||||
});
|
||||
|
||||
try {
|
||||
const rowSummary = await prisma.$queryRawUnsafe<
|
||||
Array<{ total_rows: number; distinct_configs: number }>
|
||||
>(`
|
||||
SELECT
|
||||
COUNT(*) AS total_rows,
|
||||
COUNT(DISTINCT Config_RecID) AS distinct_configs
|
||||
FROM dbo.Config_User_Defined_Field_Value;
|
||||
`);
|
||||
|
||||
const relatedRowCounts = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
config_rows: number;
|
||||
cs_result_detail_rows: number;
|
||||
config_custom_field_nonempty: number;
|
||||
}>
|
||||
>(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM dbo.Config) AS config_rows,
|
||||
(SELECT COUNT(*) FROM dbo.CS_Result_Detail) AS cs_result_detail_rows,
|
||||
(SELECT COUNT(*)
|
||||
FROM dbo.Config
|
||||
WHERE Custom_Field IS NOT NULL
|
||||
AND LEN(LTRIM(RTRIM(CONVERT(nvarchar(max), Custom_Field)))) > 0) AS config_custom_field_nonempty;
|
||||
`);
|
||||
|
||||
const topConfigs = await prisma.$queryRawUnsafe<
|
||||
Array<{ config_recid: number; field_count: number }>
|
||||
>(`
|
||||
SELECT TOP 10
|
||||
Config_RecID AS config_recid,
|
||||
COUNT(*) AS field_count
|
||||
FROM dbo.Config_User_Defined_Field_Value
|
||||
GROUP BY Config_RecID
|
||||
ORDER BY field_count DESC, config_recid ASC;
|
||||
`);
|
||||
|
||||
const customFieldSamples = await prisma.$queryRawUnsafe<
|
||||
Array<{ config_recid: number; custom_field_prefix: string }>
|
||||
>(`
|
||||
SELECT TOP 5
|
||||
Config_RecID AS config_recid,
|
||||
LEFT(CONVERT(nvarchar(max), Custom_Field), 250) AS custom_field_prefix
|
||||
FROM dbo.Config
|
||||
WHERE Custom_Field IS NOT NULL
|
||||
AND LEN(LTRIM(RTRIM(CONVERT(nvarchar(max), Custom_Field)))) > 0
|
||||
ORDER BY Config_RecID ASC;
|
||||
`);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
rowSummary: rowSummary[0] ?? null,
|
||||
relatedRowCounts: relatedRowCounts[0] ?? null,
|
||||
topConfigs,
|
||||
customFieldSamples,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PrismaMssql } from "@prisma/adapter-mssql";
|
||||
import { PrismaClient } from "./generated/prisma/client";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const outputPath =
|
||||
process.argv[2] ??
|
||||
process.env.CONFIG_OUTPUT_FILE ??
|
||||
"configurations-first-10-with-relations.json";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is not set.");
|
||||
}
|
||||
|
||||
const adapter = new PrismaMssql(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
try {
|
||||
const configurations = await prisma.configuration.findMany({
|
||||
take: 10,
|
||||
orderBy: { configRecId: "asc" },
|
||||
include: {
|
||||
configStatus: true,
|
||||
configurationAudits: {
|
||||
orderBy: { lastUpdatedUtc: "desc" },
|
||||
include: {
|
||||
configurationValues: {
|
||||
orderBy: { configurationAuditValueRecId: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (configurations.length === 0) {
|
||||
console.error("No configurations found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(configurations, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { PrismaMssql } from "@prisma/adapter-mssql";
|
||||
import { Prisma, PrismaClient } from "./generated/prisma/client";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is not set.");
|
||||
}
|
||||
|
||||
const adapter = new PrismaMssql(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
type CandidateTable = { table_name: string };
|
||||
type CandidateColumn = { table_name: string; column_name: string };
|
||||
|
||||
type DmmfField = {
|
||||
name: string;
|
||||
dbName: string | null;
|
||||
};
|
||||
|
||||
type DmmfModel = {
|
||||
name: string;
|
||||
dbName: string | null;
|
||||
fields: DmmfField[];
|
||||
};
|
||||
|
||||
const TABLE_PATTERN = /config|configur/i;
|
||||
const VALUE_COLUMN_PATTERN = /value|field|question|token/i;
|
||||
const TOP_CONFIG_LIMIT = 150;
|
||||
const CONFIG_KEY_COLUMNS = new Set([
|
||||
"Config_RecID",
|
||||
"Configuration_RecID",
|
||||
"Configuration_RecId",
|
||||
]);
|
||||
|
||||
function byName(a: string, b: string) {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
try {
|
||||
const models = Prisma.dmmf.datamodel.models as unknown as DmmfModel[];
|
||||
|
||||
const configModels = models
|
||||
.map((model) => ({
|
||||
model,
|
||||
tableName: model.dbName ?? model.name,
|
||||
}))
|
||||
.filter(({ tableName }) => TABLE_PATTERN.test(tableName));
|
||||
|
||||
const candidateTables: CandidateTable[] = configModels
|
||||
.map(({ tableName }) => ({ table_name: tableName }))
|
||||
.sort((a, b) => byName(a.table_name, b.table_name));
|
||||
|
||||
const candidateColumns: CandidateColumn[] = configModels
|
||||
.flatMap(({ model, tableName }) =>
|
||||
model.fields
|
||||
.map((field) => field.dbName ?? field.name)
|
||||
.filter((columnName) => VALUE_COLUMN_PATTERN.test(columnName))
|
||||
.map((columnName) => ({
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
}))
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
byName(a.table_name, b.table_name) ||
|
||||
byName(a.column_name, b.column_name)
|
||||
);
|
||||
|
||||
const valueTablesWithConfigKey: CandidateTable[] = configModels
|
||||
.filter(({ model }) => {
|
||||
const columnNames = model.fields.map(
|
||||
(field) => field.dbName ?? field.name
|
||||
);
|
||||
const hasConfigKey = columnNames.some((column) =>
|
||||
CONFIG_KEY_COLUMNS.has(column)
|
||||
);
|
||||
const hasValueLikeColumn = columnNames.some((column) =>
|
||||
VALUE_COLUMN_PATTERN.test(column)
|
||||
);
|
||||
return hasConfigKey && hasValueLikeColumn;
|
||||
})
|
||||
.map(({ tableName }) => ({ table_name: tableName }))
|
||||
.sort((a, b) => byName(a.table_name, b.table_name));
|
||||
|
||||
const [
|
||||
configRows,
|
||||
auditRows,
|
||||
auditValueRows,
|
||||
nonNullCustomFields,
|
||||
groupedAuditTokens,
|
||||
topConfigs,
|
||||
] = await prisma.$transaction([
|
||||
prisma.configuration.count(),
|
||||
prisma.configurationAudit.count(),
|
||||
prisma.configurationAuditValue.count(),
|
||||
prisma.configuration.findMany({
|
||||
where: { customField: { not: null } },
|
||||
select: { customField: true },
|
||||
}),
|
||||
prisma.configurationAuditValue.groupBy({
|
||||
by: ["auditToken"],
|
||||
_count: true,
|
||||
orderBy: [{ _count: { auditToken: "desc" } }, { auditToken: "asc" }],
|
||||
take: 20,
|
||||
}),
|
||||
prisma.configuration.findMany({
|
||||
take: TOP_CONFIG_LIMIT,
|
||||
orderBy: { configRecId: "asc" },
|
||||
include: {
|
||||
configurationAudits: {
|
||||
orderBy: { configurationAuditRecId: "asc" },
|
||||
include: {
|
||||
configurationValues: {
|
||||
orderBy: { configurationAuditValueRecId: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const configCustomFieldNonempty = nonNullCustomFields.reduce((count, row) => {
|
||||
return row.customField?.trim() ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
const rowStats = {
|
||||
config_rows: configRows,
|
||||
config_custom_field_nonempty: configCustomFieldNonempty,
|
||||
audit_rows: auditRows,
|
||||
audit_value_rows: auditValueRows,
|
||||
};
|
||||
|
||||
const topAuditTokens = groupedAuditTokens.map(({ auditToken, _count }) => ({
|
||||
audit_token: auditToken,
|
||||
row_count: _count,
|
||||
}));
|
||||
|
||||
const output = {
|
||||
candidateTables,
|
||||
candidateColumns,
|
||||
valueTablesWithConfigKey,
|
||||
rowStats,
|
||||
topAuditTokens,
|
||||
topConfigs,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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";
|
||||
|
||||
type EnvMap = Record<string, string>;
|
||||
|
||||
type Summary = {
|
||||
cwTotal: number;
|
||||
apiTotal: number;
|
||||
eligibleForSync: number;
|
||||
missingSrServiceRecId: number;
|
||||
missingMemberRecId: number;
|
||||
missingParentTicketInApi: number;
|
||||
missingAuthorMappingInApi: number;
|
||||
eligibleButMissingInApi: number;
|
||||
sampleMissingParentTicketIds: number[];
|
||||
sampleMissingAuthorMemberRecIds: number[];
|
||||
sampleEligibleButMissingNoteIds: number[];
|
||||
topMissingAuthorMemberRecIds: Array<{ memberRecId: number; count: number }>;
|
||||
topMissingParentTicketIds: Array<{ srServiceRecId: number; count: number }>;
|
||||
automateApi: {
|
||||
total: number;
|
||||
eligibleForSync: number;
|
||||
missingSrServiceRecId: number;
|
||||
missingMemberRecId: number;
|
||||
missingParentTicketInApi: number;
|
||||
missingAuthorMappingInApi: number;
|
||||
eligibleButMissingInApi: number;
|
||||
sampleMemberRecIds: number[];
|
||||
sampleNoteIdsMissingInApi: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const isAutomateApiAuthor = (
|
||||
createdBy: string | null,
|
||||
originalAuthor: string | null
|
||||
): boolean => {
|
||||
const normalizedCreatedBy = createdBy?.trim().toLowerCase() ?? "";
|
||||
const normalizedOriginalAuthor = originalAuthor?.trim().toLowerCase() ?? "";
|
||||
return (
|
||||
normalizedCreatedBy.includes("automateapi") ||
|
||||
normalizedOriginalAuthor.includes("automateapi")
|
||||
);
|
||||
};
|
||||
|
||||
const parseEnvFile = (path: string): EnvMap => {
|
||||
const envData = readFileSync(path, "utf8");
|
||||
const out: EnvMap = {};
|
||||
|
||||
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 readApiEnv = (): EnvMap => {
|
||||
const candidates = [
|
||||
resolve(import.meta.dir, "../api/.env"),
|
||||
resolve(process.cwd(), "../api/.env"),
|
||||
resolve(process.cwd(), "api/.env"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return parseEnvFile(candidate);
|
||||
} catch {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const apiEnv = readApiEnv();
|
||||
|
||||
const cwDatabaseUrl =
|
||||
process.env.CW_DATABASE_URL ||
|
||||
process.env.DATABASE_URL ||
|
||||
apiEnv.CW_DATABASE_URL;
|
||||
const apiDatabaseUrl =
|
||||
process.env.API_DATABASE_URL ||
|
||||
process.env.OPTIMA_API_DATABASE_URL ||
|
||||
apiEnv.API_DATABASE_URL ||
|
||||
apiEnv.OPTIMA_API_DATABASE_URL ||
|
||||
apiEnv.DATABASE_URL;
|
||||
|
||||
if (!cwDatabaseUrl) {
|
||||
throw new Error("Missing CW DB URL. Set CW_DATABASE_URL or DATABASE_URL.");
|
||||
}
|
||||
|
||||
if (!apiDatabaseUrl) {
|
||||
throw new Error(
|
||||
"Missing API DB URL. Set API_DATABASE_URL/OPTIMA_API_DATABASE_URL or provide api/.env DATABASE_URL."
|
||||
);
|
||||
}
|
||||
|
||||
const cwPrisma = new CwPrismaClient({
|
||||
adapter: new PrismaMssql(cwDatabaseUrl),
|
||||
});
|
||||
const apiPrisma = new ApiPrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: apiDatabaseUrl }),
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("[diag] Loading API reference sets...");
|
||||
const [apiNotes, apiTickets, apiUsers] = await Promise.all([
|
||||
apiPrisma.serviceTicketNote.findMany({ select: { id: true } }),
|
||||
apiPrisma.serviceTicket.findMany({ select: { id: true } }),
|
||||
apiPrisma.user.findMany({ select: { cwMemberId: true } }),
|
||||
]);
|
||||
|
||||
const apiNoteIds = new Set<number>(apiNotes.map((r) => r.id));
|
||||
const apiTicketIds = new Set<number>(apiTickets.map((r) => r.id));
|
||||
const apiUserMemberIds = new Set<number>(
|
||||
apiUsers
|
||||
.map((r) => r.cwMemberId)
|
||||
.filter((v): v is number => Number.isInteger(v))
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[diag] API sets: notes=${apiNoteIds.size} tickets=${apiTicketIds.size} usersWithCwMemberId=${apiUserMemberIds.size}`
|
||||
);
|
||||
|
||||
const cwTotal = await cwPrisma.ticketNote.count();
|
||||
const apiTotal = apiNoteIds.size;
|
||||
|
||||
let missingSrServiceRecId = 0;
|
||||
let missingMemberRecId = 0;
|
||||
let missingParentTicketInApi = 0;
|
||||
let missingAuthorMappingInApi = 0;
|
||||
let eligibleForSync = 0;
|
||||
let eligibleButMissingInApi = 0;
|
||||
|
||||
let automateTotal = 0;
|
||||
let automateEligibleForSync = 0;
|
||||
let automateMissingSrServiceRecId = 0;
|
||||
let automateMissingMemberRecId = 0;
|
||||
let automateMissingParentTicketInApi = 0;
|
||||
let automateMissingAuthorMappingInApi = 0;
|
||||
let automateEligibleButMissingInApi = 0;
|
||||
|
||||
const sampleMissingParentTicketIds: number[] = [];
|
||||
const sampleMissingAuthorMemberRecIds: number[] = [];
|
||||
const sampleEligibleButMissingNoteIds: number[] = [];
|
||||
const automateSampleMemberRecIds: number[] = [];
|
||||
const automateSampleNoteIdsMissingInApi: number[] = [];
|
||||
const missingAuthorCounts = new Map<number, number>();
|
||||
const missingParentCounts = new Map<number, number>();
|
||||
|
||||
let cursor = 0;
|
||||
const batchSize = 5000;
|
||||
|
||||
console.log(
|
||||
`[diag] Scanning CW TicketNote rows in batches of ${batchSize}...`
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const batch = await cwPrisma.ticketNote.findMany({
|
||||
where: {
|
||||
ticketNoteRecId: {
|
||||
gt: cursor,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
ticketNoteRecId: "asc",
|
||||
},
|
||||
select: {
|
||||
ticketNoteRecId: true,
|
||||
srServiceRecId: true,
|
||||
memberRecId: true,
|
||||
createdBy: true,
|
||||
originalAuthor: true,
|
||||
},
|
||||
take: batchSize,
|
||||
});
|
||||
|
||||
if (batch.length === 0) break;
|
||||
|
||||
for (const row of batch) {
|
||||
const noteId = row.ticketNoteRecId;
|
||||
const srServiceRecId = row.srServiceRecId;
|
||||
const memberRecId = row.memberRecId;
|
||||
const isAutomate = isAutomateApiAuthor(
|
||||
row.createdBy,
|
||||
row.originalAuthor
|
||||
);
|
||||
|
||||
if (isAutomate) {
|
||||
automateTotal++;
|
||||
if (
|
||||
memberRecId &&
|
||||
automateSampleMemberRecIds.length < 20 &&
|
||||
!automateSampleMemberRecIds.includes(memberRecId)
|
||||
) {
|
||||
automateSampleMemberRecIds.push(memberRecId);
|
||||
}
|
||||
}
|
||||
|
||||
let blocked = false;
|
||||
|
||||
if (!srServiceRecId) {
|
||||
missingSrServiceRecId++;
|
||||
if (isAutomate) {
|
||||
automateMissingSrServiceRecId++;
|
||||
}
|
||||
blocked = true;
|
||||
} else if (!apiTicketIds.has(srServiceRecId)) {
|
||||
missingParentTicketInApi++;
|
||||
if (isAutomate) {
|
||||
automateMissingParentTicketInApi++;
|
||||
}
|
||||
blocked = true;
|
||||
missingParentCounts.set(
|
||||
srServiceRecId,
|
||||
(missingParentCounts.get(srServiceRecId) ?? 0) + 1
|
||||
);
|
||||
if (sampleMissingParentTicketIds.length < 20) {
|
||||
sampleMissingParentTicketIds.push(srServiceRecId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!memberRecId) {
|
||||
missingMemberRecId++;
|
||||
if (isAutomate) {
|
||||
automateMissingMemberRecId++;
|
||||
}
|
||||
blocked = true;
|
||||
} else if (!apiUserMemberIds.has(memberRecId)) {
|
||||
missingAuthorMappingInApi++;
|
||||
if (isAutomate) {
|
||||
automateMissingAuthorMappingInApi++;
|
||||
}
|
||||
blocked = true;
|
||||
missingAuthorCounts.set(
|
||||
memberRecId,
|
||||
(missingAuthorCounts.get(memberRecId) ?? 0) + 1
|
||||
);
|
||||
if (sampleMissingAuthorMemberRecIds.length < 20) {
|
||||
sampleMissingAuthorMemberRecIds.push(memberRecId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!blocked) {
|
||||
eligibleForSync++;
|
||||
if (isAutomate) {
|
||||
automateEligibleForSync++;
|
||||
}
|
||||
if (!apiNoteIds.has(noteId)) {
|
||||
eligibleButMissingInApi++;
|
||||
if (isAutomate) {
|
||||
automateEligibleButMissingInApi++;
|
||||
if (automateSampleNoteIdsMissingInApi.length < 20) {
|
||||
automateSampleNoteIdsMissingInApi.push(noteId);
|
||||
}
|
||||
}
|
||||
if (sampleEligibleButMissingNoteIds.length < 50) {
|
||||
sampleEligibleButMissingNoteIds.push(noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cursor = batch[batch.length - 1]!.ticketNoteRecId;
|
||||
if (cursor % 50000 < batchSize) {
|
||||
console.log(`[diag] Progress cursor=${cursor}`);
|
||||
}
|
||||
}
|
||||
|
||||
const summary: Summary = {
|
||||
cwTotal,
|
||||
apiTotal,
|
||||
eligibleForSync,
|
||||
missingSrServiceRecId,
|
||||
missingMemberRecId,
|
||||
missingParentTicketInApi,
|
||||
missingAuthorMappingInApi,
|
||||
eligibleButMissingInApi,
|
||||
sampleMissingParentTicketIds,
|
||||
sampleMissingAuthorMemberRecIds,
|
||||
sampleEligibleButMissingNoteIds,
|
||||
topMissingAuthorMemberRecIds: [...missingAuthorCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([memberRecId, count]) => ({ memberRecId, count })),
|
||||
topMissingParentTicketIds: [...missingParentCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([srServiceRecId, count]) => ({ srServiceRecId, count })),
|
||||
automateApi: {
|
||||
total: automateTotal,
|
||||
eligibleForSync: automateEligibleForSync,
|
||||
missingSrServiceRecId: automateMissingSrServiceRecId,
|
||||
missingMemberRecId: automateMissingMemberRecId,
|
||||
missingParentTicketInApi: automateMissingParentTicketInApi,
|
||||
missingAuthorMappingInApi: automateMissingAuthorMappingInApi,
|
||||
eligibleButMissingInApi: automateEligibleButMissingInApi,
|
||||
sampleMemberRecIds: automateSampleMemberRecIds,
|
||||
sampleNoteIdsMissingInApi: automateSampleNoteIdsMissingInApi,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("[diag] TicketNote sync gap summary:");
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await Promise.all([cwPrisma.$disconnect(), apiPrisma.$disconnect()]);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[diag] Failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user