Files
optima/api/utils/testAdjustmentsPoll.ts

553 lines
15 KiB
TypeScript

import axios from "axios";
type JsonObject = Record<string, unknown>;
type SnapshotItem = {
key: string;
serialized: string;
data: JsonObject;
trackedRows: TrackedRow[];
trackedSignature: string;
};
type TrackedRow = {
key: string;
product: string;
onHand: string;
inventory: string;
};
const POLL_MS = 60_000;
const CW_BASE_URL =
process.env.CW_BASE_URL ??
"https://ttscw.totaltech.net/v4_6_release/apis/3.0";
const ENDPOINT = "/procurement/adjustments?pageSize=1000";
const CW_BASIC_TOKEN = process.env.CW_BASIC_TOKEN;
const CW_CLIENT_ID = process.env.CW_CLIENT_ID;
if (!CW_BASIC_TOKEN || !CW_CLIENT_ID) {
console.error(
"Missing required env vars: CW_BASIC_TOKEN and/or CW_CLIENT_ID",
);
process.exit(1);
}
const cw = axios.create({
baseURL: CW_BASE_URL,
headers: {
Authorization: `Basic ${CW_BASIC_TOKEN}`,
clientId: CW_CLIENT_ID,
"Content-Type": "application/json",
},
timeout: 30_000,
});
const isObject = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const stableStringify = (value: unknown): string => {
if (Array.isArray(value)) {
const entries = value.map((entry) => stableStringify(entry)).sort();
return `[${entries.join(",")}]`;
}
if (isObject(value)) {
const keys = Object.keys(value).sort();
const pairs = keys.map(
(key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`,
);
return `{${pairs.join(",")}}`;
}
return JSON.stringify(value);
};
const toObject = (value: unknown): JsonObject => {
if (!isObject(value)) return {};
return value;
};
const readPathValue = (obj: JsonObject, path: string): unknown => {
const parts = path.split(".");
let current: unknown = obj;
for (const part of parts) {
if (!isObject(current)) return null;
current = current[part];
}
return current;
};
const asKey = (value: unknown): string | null => {
if (typeof value === "string" && value.length > 0) return value;
if (typeof value === "number") return value.toString();
return null;
};
const firstValue = (obj: JsonObject, paths: string[]): unknown => {
for (const path of paths) {
const value = readPathValue(obj, path);
if (value === null || value === undefined || value === "") continue;
return value;
}
return null;
};
const itemKey = (adjustment: JsonObject): string => {
const keyPaths = [
"id",
"adjustmentId",
"procurementAdjustmentId",
"recordId",
"recId",
"_info.id",
"_info.href",
];
for (const keyPath of keyPaths) {
const keyValue = asKey(readPathValue(adjustment, keyPath));
if (keyValue) return keyValue;
}
return `anon:${stableStringify(adjustment)}`;
};
const summarize = (adjustment: JsonObject) => {
const pick = (...paths: string[]) => {
for (const path of paths) {
const value = readPathValue(adjustment, path);
if (value !== null && value !== undefined && value !== "") return value;
}
return "-";
};
return {
id: pick("id", "adjustmentId", "procurementAdjustmentId", "recordId"),
type: pick(
"type.name",
"type.identifier",
"type.id",
"type",
"adjustmentType.name",
"adjustmentType",
"transactionType.name",
"transactionType",
),
amount: pick("amount", "value", "total", "quantity"),
status: pick("status.name", "status", "state"),
description: pick("description", "summary", "notes"),
updatedBy: pick("_info.updatedBy", "updatedBy", "lastUpdatedBy"),
lastUpdated: pick("_info.lastUpdated", "lastUpdated", "dateUpdated"),
};
};
const normalizeValue = (value: unknown): string => {
if (value === null || value === undefined || value === "") return "-";
return toDisplayValue(value);
};
const isMeaningfulQuantity = (value: string) => value !== "-";
const toTrackedRow = (detail: JsonObject): TrackedRow | null => {
const productValue = firstValue(detail, [
"product.name",
"product.identifier",
"product.id",
"item.name",
"item.identifier",
"item.id",
"catalogItem.name",
"catalogItem.identifier",
"catalogItem.id",
"productIdentifier",
"productName",
"sku",
"identifier",
"id",
]);
const onHandValue = firstValue(detail, [
"onHand",
"onHandQty",
"onHandQuantity",
"qtyOnHand",
"quantityOnHand",
"quantity.onHand",
]);
const inventoryValue = firstValue(detail, [
"inventory",
"inventoryQty",
"inventoryLevel",
"quantity",
"qty",
]);
const onHand = normalizeValue(onHandValue);
const inventory = normalizeValue(inventoryValue);
const hasMeaningfulQuantity =
isMeaningfulQuantity(onHand) || isMeaningfulQuantity(inventory);
if (!hasMeaningfulQuantity) return null;
const product = normalizeValue(productValue);
const rowKey = `${product}|${onHand}|${inventory}`;
return {
key: rowKey,
product,
onHand,
inventory,
};
};
const getTrackedRows = (adjustment: JsonObject): TrackedRow[] => {
const detailCandidates = [
readPathValue(adjustment, "adjustmentDetails"),
readPathValue(adjustment, "details"),
readPathValue(adjustment, "lineItems"),
];
for (const candidate of detailCandidates) {
if (!Array.isArray(candidate)) continue;
const rows = candidate
.map((entry) => toTrackedRow(toObject(entry)))
.filter((entry): entry is TrackedRow => entry !== null)
.sort((a, b) => a.key.localeCompare(b.key));
if (rows.length > 0) return rows;
}
const rootRow = toTrackedRow(adjustment);
if (!rootRow) return [];
return [rootRow];
};
const toDisplayValue = (value: unknown): string => {
if (value === null || value === undefined || value === "") return "-";
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => toDisplayValue(entry)).join(",")}]`;
}
if (!isObject(value)) return String(value);
const commonObjectPaths = ["name", "identifier", "id", "value", "code"];
for (const path of commonObjectPaths) {
const objectValue = readPathValue(value, path);
if (objectValue === null || objectValue === undefined || objectValue === "")
continue;
if (typeof objectValue === "object") continue;
return String(objectValue);
}
return stableStringify(value);
};
const clean = (value: unknown): string => {
if (value === null || value === undefined || value === "") return "-";
return toDisplayValue(value);
};
const diffPaths = (
before: unknown,
after: unknown,
currentPath = "",
paths: string[] = [],
maxPaths = 6,
): string[] => {
if (paths.length >= maxPaths) return paths;
const beforeIsObject = isObject(before);
const afterIsObject = isObject(after);
if (!beforeIsObject || !afterIsObject) {
if (stableStringify(before) === stableStringify(after)) return paths;
const label = currentPath || "(root)";
paths.push(label);
return paths;
}
const keys = [
...new Set([...Object.keys(before), ...Object.keys(after)]),
].sort();
for (const key of keys) {
if (paths.length >= maxPaths) return paths;
const nextPath = currentPath ? `${currentPath}.${key}` : key;
diffPaths(before[key], after[key], nextPath, paths, maxPaths);
}
return paths;
};
const toLine = (kind: "+" | "~" | "-", adjustment: JsonObject): string => {
const s = summarize(adjustment);
return `${kind} id=${clean(s.id)} type=${clean(s.type)} status=${clean(s.status)} amount=${clean(
s.amount,
)} by=${clean(s.updatedBy)} desc=${clean(s.description)}`;
};
const updatedToLine = (before: JsonObject, after: JsonObject): string => {
const prev = summarize(before);
const next = summarize(after);
const changed: string[] = [];
if (clean(prev.status) !== clean(next.status)) {
changed.push(`status:${clean(prev.status)}${clean(next.status)}`);
}
if (clean(prev.amount) !== clean(next.amount)) {
changed.push(`amount:${clean(prev.amount)}${clean(next.amount)}`);
}
if (clean(prev.updatedBy) !== clean(next.updatedBy)) {
changed.push(`by:${clean(prev.updatedBy)}${clean(next.updatedBy)}`);
}
if (clean(prev.description) !== clean(next.description)) {
changed.push(`desc:${clean(prev.description)}${clean(next.description)}`);
}
if (clean(prev.lastUpdated) !== clean(next.lastUpdated)) {
changed.push(
`updated:${clean(prev.lastUpdated)}${clean(next.lastUpdated)}`,
);
}
const noisyFields = new Set(["_info.lastUpdated"]);
const rawDiffs = diffPaths(before, after).filter(
(path) => !noisyFields.has(path),
);
const rawDelta =
rawDiffs.length > 0 ? `fields:${rawDiffs.join(",")}` : "content changed";
const delta = changed.length > 0 ? changed.join(" | ") : rawDelta;
return `~ id=${clean(next.id)} type=${clean(next.type)} ${delta}`;
};
const formatTracked = (row: TrackedRow) =>
`product=${row.product} onHand=${row.onHand} inventory=${row.inventory}`;
const trackedAddedLine = (item: SnapshotItem) => {
const base = summarize(item.data);
const rows = item.trackedRows.slice(0, 3).map(formatTracked).join(" ; ");
const more =
item.trackedRows.length > 3
? ` ; +${item.trackedRows.length - 3} more`
: "";
return `+ id=${clean(base.id)} type=${clean(base.type)} ${rows}${more}`;
};
const trackedRemovedLine = (item: SnapshotItem) => {
const base = summarize(item.data);
const rows = item.trackedRows.slice(0, 3).map(formatTracked).join(" ; ");
const more =
item.trackedRows.length > 3
? ` ; +${item.trackedRows.length - 3} more`
: "";
return `- id=${clean(base.id)} type=${clean(base.type)} ${rows}${more}`;
};
const trackedUpdatedLine = (
beforeItem: SnapshotItem,
afterItem: SnapshotItem,
) => {
const base = summarize(afterItem.data);
const beforeMap = new Map(
beforeItem.trackedRows.map((row) => [row.product, row]),
);
const afterMap = new Map(
afterItem.trackedRows.map((row) => [row.product, row]),
);
const productKeys = [
...new Set([...beforeMap.keys(), ...afterMap.keys()]),
].sort();
const deltas: string[] = [];
for (const product of productKeys) {
const prev = beforeMap.get(product);
const next = afterMap.get(product);
if (!prev && next) {
deltas.push(
`${product} added(onHand=${next.onHand},inventory=${next.inventory})`,
);
continue;
}
if (prev && !next) {
deltas.push(`${product} removed`);
continue;
}
if (!prev || !next) continue;
const onHandChanged = prev.onHand !== next.onHand;
const inventoryChanged = prev.inventory !== next.inventory;
if (!onHandChanged && !inventoryChanged) continue;
const parts: string[] = [];
onHandChanged ? parts.push(`onHand:${prev.onHand}${next.onHand}`) : null;
inventoryChanged
? parts.push(`inventory:${prev.inventory}${next.inventory}`)
: null;
deltas.push(`${product} ${parts.join(",")}`);
}
const shown = deltas.slice(0, 3).join(" ; ");
const more = deltas.length > 3 ? ` ; +${deltas.length - 3} more` : "";
const changes = shown || "inventory/on-hand changed";
return `~ id=${clean(base.id)} type=${clean(base.type)} ${changes}${more}`;
};
const toSnapshot = (rows: unknown[]): SnapshotItem[] =>
rows.map((row) => {
const data = toObject(row);
const trackedRows = getTrackedRows(data);
const trackedSignature = stableStringify(trackedRows);
return {
key: itemKey(data),
serialized: stableStringify(data),
data,
trackedRows,
trackedSignature,
};
});
const fetchAdjustments = async (): Promise<unknown[]> => {
const response = await cw.get(ENDPOINT);
const payload = response.data;
if (Array.isArray(payload)) return payload;
if (isObject(payload) && Array.isArray(payload.data)) return payload.data;
return [];
};
const now = () => new Date().toISOString();
let previous = new Map<string, SnapshotItem>();
const run = async () => {
console.log(
`[${now()}] Watching ${CW_BASE_URL}${ENDPOINT} every ${POLL_MS / 1000}s`,
);
while (true) {
try {
const rows = await fetchAdjustments();
const snapshotItems = toSnapshot(rows);
const current = new Map(snapshotItems.map((item) => [item.key, item]));
if (previous.size === 0) {
previous = current;
console.log(
`[${now()}] Baseline captured (${current.size} adjustments)`,
);
await Bun.sleep(POLL_MS);
continue;
}
const added: SnapshotItem[] = [];
const removed: SnapshotItem[] = [];
const updated: Array<{ before: SnapshotItem; after: SnapshotItem }> = [];
for (const [key, item] of current) {
const previousItem = previous.get(key);
if (!previousItem) {
if (item.trackedRows.length === 0) continue;
added.push(item);
continue;
}
const hasTrackedRows =
item.trackedRows.length > 0 || previousItem.trackedRows.length > 0;
if (!hasTrackedRows) continue;
if (previousItem.trackedSignature !== item.trackedSignature) {
updated.push({ before: previousItem, after: item });
}
}
for (const [key, item] of previous) {
if (!current.has(key) && item.trackedRows.length > 0)
removed.push(item);
}
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
console.log(`\n[${now()}] Changes detected:`);
console.log(`- added: ${added.length}`);
console.log(`- updated: ${updated.length}`);
console.log(`- removed: ${removed.length}`);
if (added.length > 0) {
console.log("\nAdded:");
for (const item of added) {
console.log(trackedAddedLine(item));
}
}
if (updated.length > 0) {
console.log("\nUpdated:");
for (const item of updated) {
console.log(trackedUpdatedLine(item.before, item.after));
}
}
if (removed.length > 0) {
console.log("\nRemoved:");
for (const item of removed) {
console.log(trackedRemovedLine(item));
}
}
}
if (added.length === 0 && updated.length === 0 && removed.length === 0) {
console.log(`[${now()}] No changes (${current.size} adjustments)`);
}
previous = current;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(
`[${now()}] Poll failed: ${error.response?.status ?? "ERR"}`,
error.message,
);
} else {
console.error(`[${now()}] Poll failed:`, error);
}
}
await Bun.sleep(POLL_MS);
}
};
run().catch((error) => {
console.error("Watcher crashed:", error);
process.exit(1);
});