Add NTE floor on WO open and refine close-out digest rules.
Raise NTE to $400 when a new WO space opens, align /createWO defaults with SC_WO_OPEN_MIN_NTE, limit the daily digest to all non-invoiced COMPLETED WOs, and add unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
abd6423218
commit
6ecdf6ee69
11 changed files with 250 additions and 23 deletions
10
.env.example
10
.env.example
|
|
@ -41,13 +41,12 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
|
|||
# --- Space cleanup (optional) ---
|
||||
# Webex room for daily consolidated close-out digest and /confirmed ops notifications.
|
||||
# COMPLETED_OPERATIONS_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00vNjdmZmYxZTAtZmM3Ny0xMWYwLWE2MzUtZGY0ZmQ4NWUwMGMz
|
||||
# Days after any non-terminal COMPLETED status before including in the daily digest.
|
||||
# Daily digest lists all non-invoiced COMPLETED work orders (any age).
|
||||
# Auto-remove still applies only to COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE.
|
||||
# Only work orders for this service provider are included in cleanup (default: Pro-Motion).
|
||||
# SPACE_CLEANUP_PROVIDER_NAME=Pro-Motion Technology Group, LLC
|
||||
# SPACE_CLEANUP_PROVIDER_ID=2000002215
|
||||
# SPACE_CLEANUP_REMINDER_DAYS=60
|
||||
# Cron for daily consolidated digest (default 14:00 UTC). Digest only — no auto-delete.
|
||||
# Cron for daily consolidated digest (default 14:00 UTC / 10am Eastern). Digest only — no auto-delete.
|
||||
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
|
||||
|
||||
# --- Admin endpoints (/cleanup-test, /stale-workorders, /track-backfill) ---
|
||||
|
|
@ -125,7 +124,10 @@ SC_WEBHOOK_AUTH_MODE=off # off | log | enforce
|
|||
# SC_CREATE_DEFAULT_PROVIDER_NAME=Pro-Motion Technology Group, LLC
|
||||
# SC_CREATE_DEFAULT_CATEGORY=REPAIR
|
||||
# SC_CREATE_DEFAULT_PRIORITY=P24
|
||||
# SC_CREATE_DEFAULT_NTE=500
|
||||
# SC_CREATE_DEFAULT_NTE=400
|
||||
# Optional override for /createWO NTE default; if unset, uses SC_WO_OPEN_MIN_NTE (default 400).
|
||||
# When a new Webex WO space opens (first webhook), raise NTE to this floor if lower (0 = off).
|
||||
# SC_WO_OPEN_MIN_NTE=400
|
||||
# SC_CREATE_CATEGORIES=REPAIR,MAINTENANCE,PROJECT
|
||||
# SC_CREATE_PRIORITIES=P24,P1 - 4 Hours,P2 - 8 Hours
|
||||
# SC_CREATE_PROBLEM_CODES=Music completely out,Needs service,Partial Music
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"dev": "node --watch index.js",
|
||||
"start": "node index.js",
|
||||
"lint": "echo \"No linter configured\"",
|
||||
"test": "echo \"No tests configured\"",
|
||||
"test": "node --test tests/**/*.test.js",
|
||||
"test:collab": "node scripts/smoke-collab-support.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
* (admin) to inspect recent tenant-specific field samples.
|
||||
*/
|
||||
|
||||
import { getWoOpenMinNte } from '../services/workOrderNteService.js';
|
||||
|
||||
function parseCsvEnv(name, fallback = []) {
|
||||
const raw = process.env[name];
|
||||
if (!raw || !String(raw).trim()) return [...fallback];
|
||||
|
|
@ -30,6 +32,16 @@ export function isWorkOrderCreateEnabled() {
|
|||
return parseBoolEnv('SC_CREATE_ENABLED', true);
|
||||
}
|
||||
|
||||
/** Default NTE on /createWO form — SC_CREATE_DEFAULT_NTE, else SC_WO_OPEN_MIN_NTE, else 400. */
|
||||
export function getDefaultCreateNte() {
|
||||
const explicit = process.env.SC_CREATE_DEFAULT_NTE;
|
||||
if (explicit != null && String(explicit).trim() !== '') {
|
||||
return parseNumberEnv('SC_CREATE_DEFAULT_NTE', 400);
|
||||
}
|
||||
const openMin = getWoOpenMinNte();
|
||||
return openMin > 0 ? openMin : 400;
|
||||
}
|
||||
|
||||
export function getWorkOrderCreateConfig() {
|
||||
const providerId = parseNumberEnv(
|
||||
'SC_CREATE_DEFAULT_PROVIDER_ID',
|
||||
|
|
@ -48,7 +60,7 @@ export function getWorkOrderCreateConfig() {
|
|||
storePadLength: parseNumberEnv('SC_CREATE_STORE_PAD_LENGTH', 6),
|
||||
defaultCategory: process.env.SC_CREATE_DEFAULT_CATEGORY || 'REPAIR',
|
||||
defaultPriority: process.env.SC_CREATE_DEFAULT_PRIORITY || 'P24',
|
||||
defaultNte: parseNumberEnv('SC_CREATE_DEFAULT_NTE', 500),
|
||||
defaultNte: getDefaultCreateNte(),
|
||||
categories: parseCsvEnv('SC_CREATE_CATEGORIES', ['REPAIR', 'MAINTENANCE', 'PROJECT']),
|
||||
priorities: parseCsvEnv('SC_CREATE_PRIORITIES', [
|
||||
'P24',
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ export function createApp({
|
|||
(ID <code>${esc(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215')}</code>).
|
||||
Auto-remove (prune ≥14d / delete ≥60d) for <code>COMPLETED / CONFIRMED</code>,
|
||||
<code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</code>.
|
||||
All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
|
||||
a consolidated daily digest posts to the ops room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days.
|
||||
All other <code>COMPLETED</code> variants are held until one of those terminal statuses.
|
||||
A consolidated daily digest posts to the ops room for all <strong>non-invoiced</strong> COMPLETED work orders.
|
||||
Invoice PDFs trigger an approval card in the WO room; use <code>/confirmed</code> after invoice
|
||||
(sets SC CONFIRMED when allowed, otherwise records close-out via SC note + ServChan DB).
|
||||
</div>
|
||||
|
|
|
|||
11
src/services/closeoutDigestEligibility.js
Normal file
11
src/services/closeoutDigestEligibility.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Eligibility rules for the daily close-out digest (no heavy imports — safe for unit tests).
|
||||
*/
|
||||
|
||||
/** True when a non-terminal COMPLETED WO should appear in the daily close-out digest. */
|
||||
export function isCloseoutDigestCandidate(statusInfo) {
|
||||
if (!statusInfo) return false;
|
||||
if (statusInfo.isInvoiced === true) return false;
|
||||
if (statusInfo.invoiceId) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@ import { logger } from '../utils/logger.js';
|
|||
import defaultDb from '../db/mappings.js';
|
||||
import { getCompletedOperationsRoomId, parseServChanRoomTitle } from '../utils/servchanRoomTitle.js';
|
||||
import { getWoCloseout } from './woCloseoutService.js';
|
||||
import { isCloseoutDigestCandidate } from './closeoutDigestEligibility.js';
|
||||
|
||||
const REMOVE_OTHERS_AFTER_DAYS = 14;
|
||||
const DELETE_AFTER_DAYS = 60;
|
||||
const REMINDER_AFTER_DAYS = parseInt(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60', 10);
|
||||
const CLEANUP_PROVIDER_NAME = (process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC').trim();
|
||||
const CLEANUP_PROVIDER_ID = parseInt(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215', 10);
|
||||
|
||||
|
|
@ -91,21 +91,16 @@ function buildConsolidatedDigestMessage(digestRows) {
|
|||
const noun = count === 1 ? 'work order is' : 'work orders are';
|
||||
|
||||
let md = `**Daily close-out report** — ${dateLabel}\n\n`;
|
||||
md += `${count} ${noun} COMPLETED (non-terminal) for ${REMINDER_AFTER_DAYS}+ days:\n\n`;
|
||||
md += `${count} ${noun} COMPLETED without invoice:\n\n`;
|
||||
|
||||
for (const row of digestRows) {
|
||||
const header = row.headerLine || `WO-${row.woNumber || row.workOrderId}`;
|
||||
let line = `- **${header}** — ${row.statusDisplay}`;
|
||||
if (row.isInvoiced) {
|
||||
line += row.invoiceNumber ? ` — invoiced #${row.invoiceNumber}` : ' — invoiced';
|
||||
}
|
||||
line += ` — ${row.ageDays} days\n`;
|
||||
md += line;
|
||||
md += `- **${header}** — ${row.statusDisplay} — ${row.ageDays} days\n`;
|
||||
}
|
||||
|
||||
md +=
|
||||
'\nTerminal statuses (Confirmed, Cancelled, No Charge) trigger auto-remove. ' +
|
||||
'After an invoice is posted, use `/confirmed` in the WO space when resolution is verified.';
|
||||
'Invoiced work orders are excluded from this digest — use `/confirmed` in the WO space when resolution is verified.';
|
||||
|
||||
return md;
|
||||
}
|
||||
|
|
@ -147,7 +142,7 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
|||
const scope = remindersOnly ? 'digest only' : 'full cleanup';
|
||||
|
||||
console.log(
|
||||
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | digest ≥${REMINDER_AFTER_DAYS}d)...`
|
||||
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | digest: all non-invoiced COMPLETED)...`
|
||||
);
|
||||
|
||||
const results = [];
|
||||
|
|
@ -234,14 +229,14 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
|||
if (classification.category === 'remind') {
|
||||
remindEligible++;
|
||||
|
||||
if (ageDays < REMINDER_AFTER_DAYS) {
|
||||
if (!isCloseoutDigestCandidate(statusInfo)) {
|
||||
results.push({
|
||||
woId: mapping.workOrderId,
|
||||
roomId: mapping.roomId,
|
||||
action: 'skipped',
|
||||
days: ageDays,
|
||||
status: statusDisplay,
|
||||
reason: `${statusDisplay} — ${ageDays} days (digest at ${REMINDER_AFTER_DAYS})`,
|
||||
reason: 'invoiced — excluded from close-out digest (use /confirmed)',
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
|
|
@ -267,8 +262,6 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
|||
ageDays,
|
||||
roomId: mapping.roomId,
|
||||
headerLine,
|
||||
isInvoiced: statusInfo.isInvoiced === true,
|
||||
invoiceNumber: statusInfo.invoiceNumber ?? null,
|
||||
});
|
||||
|
||||
results.push({
|
||||
|
|
@ -442,7 +435,6 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
|||
failed,
|
||||
dryRun,
|
||||
remindersOnly,
|
||||
reminderAfterDays: REMINDER_AFTER_DAYS,
|
||||
};
|
||||
console.log(`[SPACE-CLEANUP] ${mode} Job completed:`, summary);
|
||||
logger('SPACE-CLEANUP', `${mode} Job completed: ${JSON.stringify(summary)}`);
|
||||
|
|
@ -508,3 +500,5 @@ async function removeAllOtherMembers(roomId) {
|
|||
logger('SPACE-CLEANUP', `removeAllOtherMembers failed for ${roomId}: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export { isCloseoutDigestCandidate } from './closeoutDigestEligibility.js';
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ import {
|
|||
shouldSuppressAttachmentPost,
|
||||
shouldSuppressWebhookNote,
|
||||
} from '../db/uploadEchoSuppress.js';
|
||||
import {
|
||||
ensureWorkOrderMinimumNte,
|
||||
getWoOpenMinNte,
|
||||
} from './workOrderNteServiceDefault.js';
|
||||
|
||||
export function createWebhookProcessor(options = {}) {
|
||||
const {
|
||||
|
|
@ -286,6 +290,20 @@ export function createWebhookProcessor(options = {}) {
|
|||
}
|
||||
|
||||
if (roomJustCreated) {
|
||||
const minNte = getWoOpenMinNte();
|
||||
if (minNte > 0) {
|
||||
ensureWorkOrderMinimumNte(workOrderId, {
|
||||
minNte,
|
||||
hintNte: obj.Nte,
|
||||
}).catch((e) => {
|
||||
logger(
|
||||
'webhookProcessor:nte',
|
||||
`Min NTE error for ${workOrderId}: ${e.message}`,
|
||||
'warn'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
scheduleAttachmentPost({
|
||||
roomId,
|
||||
workOrderId,
|
||||
|
|
|
|||
43
src/services/workOrderNteService.js
Normal file
43
src/services/workOrderNteService.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Minimum NTE floor when a new Webex WO space is opened (first webhook).
|
||||
* Pure logic — no ServiceChannel client import (safe for unit tests).
|
||||
*/
|
||||
|
||||
function parseNumberEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/** Minimum NTE applied on new space open; 0 disables the feature. */
|
||||
export function getWoOpenMinNte() {
|
||||
return parseNumberEnv('SC_WO_OPEN_MIN_NTE', 400);
|
||||
}
|
||||
|
||||
export function createWorkOrderNteService({
|
||||
getWorkOrderForNte: getWo,
|
||||
updateWorkOrderNte: updateNte,
|
||||
} = {}) {
|
||||
if (typeof getWo !== 'function' || typeof updateNte !== 'function') {
|
||||
throw new Error('createWorkOrderNteService requires getWorkOrderForNte and updateWorkOrderNte');
|
||||
}
|
||||
|
||||
async function ensureWorkOrderMinimumNte(woId, { minNte = 400, hintNte = null } = {}) {
|
||||
if (!woId || minNte <= 0) return { updated: false, skipped: true };
|
||||
|
||||
let current = hintNte != null ? Number(hintNte) : NaN;
|
||||
if (!Number.isFinite(current)) {
|
||||
const wo = await getWo(woId);
|
||||
current = Number(wo?.Nte);
|
||||
}
|
||||
if (!Number.isFinite(current) || current >= minNte) {
|
||||
return { updated: false, nte: current };
|
||||
}
|
||||
|
||||
await updateNte(woId, minNte);
|
||||
return { updated: true, previousNte: current, nte: minNte };
|
||||
}
|
||||
|
||||
return { ensureWorkOrderMinimumNte };
|
||||
}
|
||||
28
src/services/workOrderNteServiceDefault.js
Normal file
28
src/services/workOrderNteServiceDefault.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Production wiring for workOrderNteService (loads ServiceChannel client).
|
||||
*/
|
||||
|
||||
import {
|
||||
getWorkOrderForNte,
|
||||
updateWorkOrderNte,
|
||||
} from '../integrations/serviceChannel/client.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { createWorkOrderNteService } from './workOrderNteService.js';
|
||||
|
||||
const { ensureWorkOrderMinimumNte: ensureBase } = createWorkOrderNteService({
|
||||
getWorkOrderForNte,
|
||||
updateWorkOrderNte,
|
||||
});
|
||||
|
||||
export async function ensureWorkOrderMinimumNte(woId, options = {}) {
|
||||
const result = await ensureBase(woId, options);
|
||||
if (result.updated) {
|
||||
logger(
|
||||
'workOrderNteService',
|
||||
`WO ${woId} NTE raised from ${result.previousNte} to ${result.nte}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export { getWoOpenMinNte } from './workOrderNteService.js';
|
||||
28
tests/spaceCleanupDigest.test.js
Normal file
28
tests/spaceCleanupDigest.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isCloseoutDigestCandidate } from '../src/services/closeoutDigestEligibility.js';
|
||||
|
||||
test('isCloseoutDigestCandidate rejects when isInvoiced is true', () => {
|
||||
assert.equal(
|
||||
isCloseoutDigestCandidate({ isInvoiced: true, invoiceId: null }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('isCloseoutDigestCandidate rejects when invoiceId is set', () => {
|
||||
assert.equal(
|
||||
isCloseoutDigestCandidate({ isInvoiced: false, invoiceId: 12345 }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('isCloseoutDigestCandidate accepts non-invoiced WO', () => {
|
||||
assert.equal(
|
||||
isCloseoutDigestCandidate({ isInvoiced: false, invoiceId: null }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('isCloseoutDigestCandidate rejects null statusInfo', () => {
|
||||
assert.equal(isCloseoutDigestCandidate(null), false);
|
||||
});
|
||||
91
tests/workOrderNteService.test.js
Normal file
91
tests/workOrderNteService.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWorkOrderNteService,
|
||||
getWoOpenMinNte,
|
||||
} from '../src/services/workOrderNteService.js';
|
||||
|
||||
test('getWoOpenMinNte defaults to 400', () => {
|
||||
const prev = process.env.SC_WO_OPEN_MIN_NTE;
|
||||
delete process.env.SC_WO_OPEN_MIN_NTE;
|
||||
assert.equal(getWoOpenMinNte(), 400);
|
||||
if (prev !== undefined) process.env.SC_WO_OPEN_MIN_NTE = prev;
|
||||
});
|
||||
|
||||
test('getWoOpenMinNte returns 0 when disabled', () => {
|
||||
const prev = process.env.SC_WO_OPEN_MIN_NTE;
|
||||
process.env.SC_WO_OPEN_MIN_NTE = '0';
|
||||
assert.equal(getWoOpenMinNte(), 0);
|
||||
if (prev !== undefined) process.env.SC_WO_OPEN_MIN_NTE = prev;
|
||||
else delete process.env.SC_WO_OPEN_MIN_NTE;
|
||||
});
|
||||
|
||||
test('ensureWorkOrderMinimumNte skips when NTE is at or above floor', async () => {
|
||||
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
|
||||
getWorkOrderForNte: async () => ({ Nte: 500 }),
|
||||
updateWorkOrderNte: async () => {
|
||||
throw new Error('should not PATCH');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureWorkOrderMinimumNte(123, { minNte: 400, hintNte: 500 });
|
||||
assert.deepEqual(result, { updated: false, nte: 500 });
|
||||
});
|
||||
|
||||
test('ensureWorkOrderMinimumNte skips when hintNte equals floor', async () => {
|
||||
let patched = false;
|
||||
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
|
||||
getWorkOrderForNte: async () => {
|
||||
throw new Error('should not GET');
|
||||
},
|
||||
updateWorkOrderNte: async () => {
|
||||
patched = true;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureWorkOrderMinimumNte(123, { minNte: 400, hintNte: 400 });
|
||||
assert.equal(patched, false);
|
||||
assert.deepEqual(result, { updated: false, nte: 400 });
|
||||
});
|
||||
|
||||
test('ensureWorkOrderMinimumNte bumps when hintNte is below floor', async () => {
|
||||
let patchedTo = null;
|
||||
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
|
||||
getWorkOrderForNte: async () => {
|
||||
throw new Error('should not GET');
|
||||
},
|
||||
updateWorkOrderNte: async (_woId, nte) => {
|
||||
patchedTo = nte;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureWorkOrderMinimumNte(456, { minNte: 400, hintNte: 250 });
|
||||
assert.equal(patchedTo, 400);
|
||||
assert.deepEqual(result, { updated: true, previousNte: 250, nte: 400 });
|
||||
});
|
||||
|
||||
test('ensureWorkOrderMinimumNte bumps null/zero NTE via GET fallback', async () => {
|
||||
let patchedTo = null;
|
||||
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
|
||||
getWorkOrderForNte: async () => ({ Nte: 0 }),
|
||||
updateWorkOrderNte: async (_woId, nte) => {
|
||||
patchedTo = nte;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureWorkOrderMinimumNte(789, { minNte: 400 });
|
||||
assert.equal(patchedTo, 400);
|
||||
assert.deepEqual(result, { updated: true, previousNte: 0, nte: 400 });
|
||||
});
|
||||
|
||||
test('ensureWorkOrderMinimumNte skips when minNte is 0', async () => {
|
||||
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
|
||||
getWorkOrderForNte: async () => ({ Nte: 100 }),
|
||||
updateWorkOrderNte: async () => {
|
||||
throw new Error('should not PATCH');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureWorkOrderMinimumNte(123, { minNte: 0, hintNte: 100 });
|
||||
assert.deepEqual(result, { updated: false, skipped: true });
|
||||
});
|
||||
Loading…
Reference in a new issue