@goxtechnologies/connectwise-psa-mcp 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/connectwise_api.db +0 -0
- package/data/manage.json +298179 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/analytics-extended.d.ts +6 -0
- package/dist/operations/analytics-extended.d.ts.map +1 -0
- package/dist/operations/analytics-extended.js +825 -0
- package/dist/operations/analytics-extended.js.map +1 -0
- package/dist/operations/analytics-msp-assets.d.ts +3 -0
- package/dist/operations/analytics-msp-assets.d.ts.map +1 -0
- package/dist/operations/analytics-msp-assets.js +180 -0
- package/dist/operations/analytics-msp-assets.js.map +1 -0
- package/dist/operations/analytics-msp-clients.d.ts +3 -0
- package/dist/operations/analytics-msp-clients.d.ts.map +1 -0
- package/dist/operations/analytics-msp-clients.js +198 -0
- package/dist/operations/analytics-msp-clients.js.map +1 -0
- package/dist/operations/analytics-msp-comms.d.ts +3 -0
- package/dist/operations/analytics-msp-comms.d.ts.map +1 -0
- package/dist/operations/analytics-msp-comms.js +127 -0
- package/dist/operations/analytics-msp-comms.js.map +1 -0
- package/dist/operations/analytics-msp-contracts.d.ts +3 -0
- package/dist/operations/analytics-msp-contracts.d.ts.map +1 -0
- package/dist/operations/analytics-msp-contracts.js +91 -0
- package/dist/operations/analytics-msp-contracts.js.map +1 -0
- package/dist/operations/analytics-msp-financial.d.ts +3 -0
- package/dist/operations/analytics-msp-financial.d.ts.map +1 -0
- package/dist/operations/analytics-msp-financial.js +300 -0
- package/dist/operations/analytics-msp-financial.js.map +1 -0
- package/dist/operations/analytics-msp-procurement.d.ts +3 -0
- package/dist/operations/analytics-msp-procurement.d.ts.map +1 -0
- package/dist/operations/analytics-msp-procurement.js +78 -0
- package/dist/operations/analytics-msp-procurement.js.map +1 -0
- package/dist/operations/analytics-msp-projects.d.ts +3 -0
- package/dist/operations/analytics-msp-projects.d.ts.map +1 -0
- package/dist/operations/analytics-msp-projects.js +190 -0
- package/dist/operations/analytics-msp-projects.js.map +1 -0
- package/dist/operations/analytics-msp-sales.d.ts +3 -0
- package/dist/operations/analytics-msp-sales.d.ts.map +1 -0
- package/dist/operations/analytics-msp-sales.js +99 -0
- package/dist/operations/analytics-msp-sales.js.map +1 -0
- package/dist/operations/analytics-msp-schedule.d.ts +3 -0
- package/dist/operations/analytics-msp-schedule.d.ts.map +1 -0
- package/dist/operations/analytics-msp-schedule.js +339 -0
- package/dist/operations/analytics-msp-schedule.js.map +1 -0
- package/dist/operations/analytics-msp-team.d.ts +3 -0
- package/dist/operations/analytics-msp-team.d.ts.map +1 -0
- package/dist/operations/analytics-msp-team.js +195 -0
- package/dist/operations/analytics-msp-team.js.map +1 -0
- package/dist/operations/analytics-msp-tickets.d.ts +3 -0
- package/dist/operations/analytics-msp-tickets.d.ts.map +1 -0
- package/dist/operations/analytics-msp-tickets.js +578 -0
- package/dist/operations/analytics-msp-tickets.js.map +1 -0
- package/dist/operations/analytics-msp-time.d.ts +3 -0
- package/dist/operations/analytics-msp-time.d.ts.map +1 -0
- package/dist/operations/analytics-msp-time.js +485 -0
- package/dist/operations/analytics-msp-time.js.map +1 -0
- package/dist/operations/analytics-msp-utils.d.ts +49 -0
- package/dist/operations/analytics-msp-utils.d.ts.map +1 -0
- package/dist/operations/analytics-msp-utils.js +157 -0
- package/dist/operations/analytics-msp-utils.js.map +1 -0
- package/dist/operations/analytics.d.ts +9 -0
- package/dist/operations/analytics.d.ts.map +1 -0
- package/dist/operations/analytics.js +742 -0
- package/dist/operations/analytics.js.map +1 -0
- package/dist/operations/executor.d.ts +10 -0
- package/dist/operations/executor.d.ts.map +1 -0
- package/dist/operations/executor.js +243 -0
- package/dist/operations/executor.js.map +1 -0
- package/dist/operations/registry.d.ts +16 -0
- package/dist/operations/registry.d.ts.map +1 -0
- package/dist/operations/registry.js +847 -0
- package/dist/operations/registry.js.map +1 -0
- package/dist/services/api-database.d.ts +38 -0
- package/dist/services/api-database.d.ts.map +1 -0
- package/dist/services/api-database.js +191 -0
- package/dist/services/api-database.js.map +1 -0
- package/dist/services/cache.d.ts +12 -0
- package/dist/services/cache.d.ts.map +1 -0
- package/dist/services/cache.js +32 -0
- package/dist/services/cache.js.map +1 -0
- package/dist/services/connectwise-api.d.ts +43 -0
- package/dist/services/connectwise-api.d.ts.map +1 -0
- package/dist/services/connectwise-api.js +198 -0
- package/dist/services/connectwise-api.js.map +1 -0
- package/dist/services/db-builder.d.ts +11 -0
- package/dist/services/db-builder.d.ts.map +1 -0
- package/dist/services/db-builder.js +237 -0
- package/dist/services/db-builder.js.map +1 -0
- package/dist/services/fast-memory.d.ts +39 -0
- package/dist/services/fast-memory.d.ts.map +1 -0
- package/dist/services/fast-memory.js +147 -0
- package/dist/services/fast-memory.js.map +1 -0
- package/dist/services/load-env.d.ts +15 -0
- package/dist/services/load-env.d.ts.map +1 -0
- package/dist/services/load-env.js +59 -0
- package/dist/services/load-env.js.map +1 -0
- package/dist/tools/batch.d.ts +9 -0
- package/dist/tools/batch.d.ts.map +1 -0
- package/dist/tools/batch.js +159 -0
- package/dist/tools/batch.js.map +1 -0
- package/dist/tools/composite.d.ts +9 -0
- package/dist/tools/composite.d.ts.map +1 -0
- package/dist/tools/composite.js +353 -0
- package/dist/tools/composite.js.map +1 -0
- package/dist/tools/discovery.d.ts +9 -0
- package/dist/tools/discovery.d.ts.map +1 -0
- package/dist/tools/discovery.js +245 -0
- package/dist/tools/discovery.js.map +1 -0
- package/dist/tools/execution.d.ts +9 -0
- package/dist/tools/execution.d.ts.map +1 -0
- package/dist/tools/execution.js +130 -0
- package/dist/tools/execution.js.map +1 -0
- package/dist/tools/memory.d.ts +9 -0
- package/dist/tools/memory.d.ts.map +1 -0
- package/dist/tools/memory.js +152 -0
- package/dist/tools/memory.js.map +1 -0
- package/dist/tools/operations.d.ts +9 -0
- package/dist/tools/operations.d.ts.map +1 -0
- package/dist/tools/operations.js +214 -0
- package/dist/tools/operations.js.map +1 -0
- package/dist/tools/pagination.d.ts +9 -0
- package/dist/tools/pagination.d.ts.map +1 -0
- package/dist/tools/pagination.js +133 -0
- package/dist/tools/pagination.js.map +1 -0
- package/dist/tools/validation.d.ts +9 -0
- package/dist/tools/validation.d.ts.map +1 -0
- package/dist/tools/validation.js +705 -0
- package/dist/tools/validation.js.map +1 -0
- package/dist/types/index.d.ts +145 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/operations.d.ts +30 -0
- package/dist/types/operations.d.ts.map +1 -0
- package/dist/types/operations.js +3 -0
- package/dist/types/operations.js.map +1 -0
- package/dist/utils/conditions.d.ts +20 -0
- package/dist/utils/conditions.d.ts.map +1 -0
- package/dist/utils/conditions.js +78 -0
- package/dist/utils/conditions.js.map +1 -0
- package/dist/utils/formatters.d.ts +35 -0
- package/dist/utils/formatters.d.ts.map +1 -0
- package/dist/utils/formatters.js +337 -0
- package/dist/utils/formatters.js.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
// ConnectWise PSA MCP Server — Validation & Approval Tools
|
|
2
|
+
//
|
|
3
|
+
// cw_validate_timesheets: 8-rule validation engine
|
|
4
|
+
// cw_accrual_balance: fetch and format accrual balances for a member
|
|
5
|
+
// cw_approve_timesheets: full approval workflow — validate, reject violations, approve clean sheets
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { getAPI } from '../services/connectwise-api.js';
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Tool definitions
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export const validationTools = [
|
|
16
|
+
{
|
|
17
|
+
name: 'cw_validate_timesheets',
|
|
18
|
+
description: 'Validate all time entries for a member within a date range against 8 business rules. ' +
|
|
19
|
+
'Returns a list of violations tagged as either "reject" (must be corrected) or "warn" (advisory). ' +
|
|
20
|
+
'Rules: 5-minute rounding, deduction rounding, overlap detection, missing notes, department match, overtime flag, pause rules, and charge-code weekly limits.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
member_identifier: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'ConnectWise member identifier (login name)',
|
|
27
|
+
},
|
|
28
|
+
start_date: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Period start date in ISO format (YYYY-MM-DD)',
|
|
31
|
+
},
|
|
32
|
+
end_date: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Period end date in ISO format (YYYY-MM-DD)',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['member_identifier', 'start_date', 'end_date'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'cw_accrual_balance',
|
|
42
|
+
description: 'Retrieve and display the current accrual balances (vacation, sick, PTO, holiday, etc.) for a ConnectWise member.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
member_identifier: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'ConnectWise member identifier (login name)',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ['member_identifier'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'cw_approve_timesheets',
|
|
56
|
+
description: 'Full timesheet approval workflow. Fetches timesheets for one or all members in a date range, ' +
|
|
57
|
+
'validates each against 8 business rules, then: rejects entries with REJECT violations (two-step: ' +
|
|
58
|
+
'reject individual entries first with comments, then reject the timesheet), and approves timesheets ' +
|
|
59
|
+
'with no REJECT violations. Use dry_run=true to preview without making changes. ' +
|
|
60
|
+
'Dry-run by default for safety.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
start_date: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: 'Period start date in YYYY-MM-DD format',
|
|
67
|
+
},
|
|
68
|
+
end_date: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Period end date in YYYY-MM-DD format',
|
|
71
|
+
},
|
|
72
|
+
member_identifier: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'Optional: specific member identifier. If omitted, processes ALL members with timesheets in the period.',
|
|
75
|
+
},
|
|
76
|
+
dry_run: {
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
description: 'If true, validate and report but do NOT approve/reject anything in ConnectWise. Default: true (safe by default).',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ['start_date', 'end_date'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Validation helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Timezone handling
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// ConnectWise stores times in UTC but they represent local time (Eastern).
|
|
92
|
+
// All validation (5-min rounding, overtime, overlaps, pauses) must use
|
|
93
|
+
// local time, not UTC. Default: America/Toronto (ET).
|
|
94
|
+
const LOCAL_TZ = process.env.CW_TIMEZONE ?? 'America/Toronto';
|
|
95
|
+
/** Convert a UTC ISO datetime string to local time components. */
|
|
96
|
+
function toLocal(iso) {
|
|
97
|
+
// Parse the UTC date, then get its representation in local timezone
|
|
98
|
+
const utc = new Date(iso);
|
|
99
|
+
// Use Intl to get the local time parts
|
|
100
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
101
|
+
timeZone: LOCAL_TZ,
|
|
102
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
103
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
104
|
+
hour12: false,
|
|
105
|
+
}).formatToParts(utc);
|
|
106
|
+
const get = (type) => {
|
|
107
|
+
const part = parts.find(p => p.type === type);
|
|
108
|
+
return part ? parseInt(part.value, 10) : 0;
|
|
109
|
+
};
|
|
110
|
+
// Build a Date-like object with local time values
|
|
111
|
+
const local = new Date(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
|
112
|
+
return local;
|
|
113
|
+
}
|
|
114
|
+
/** Round a number to 2 decimal places for hour comparisons. */
|
|
115
|
+
function round2(n) {
|
|
116
|
+
return Math.round(n * 100) / 100;
|
|
117
|
+
}
|
|
118
|
+
/** Parse an ISO datetime string and return local { hours, minutes }. */
|
|
119
|
+
function parseHHMM(iso) {
|
|
120
|
+
if (!iso)
|
|
121
|
+
return null;
|
|
122
|
+
const local = toLocal(iso);
|
|
123
|
+
return { hours: local.getHours(), minutes: local.getMinutes() };
|
|
124
|
+
}
|
|
125
|
+
/** Return total minutes since midnight in LOCAL time for a time string. */
|
|
126
|
+
function toMinutes(iso) {
|
|
127
|
+
const t = parseHHMM(iso);
|
|
128
|
+
if (!t)
|
|
129
|
+
return null;
|
|
130
|
+
return t.hours * 60 + t.minutes;
|
|
131
|
+
}
|
|
132
|
+
/** Extract local YYYY-MM-DD from an ISO datetime string (in local timezone). */
|
|
133
|
+
function toDateKey(iso) {
|
|
134
|
+
const local = toLocal(iso);
|
|
135
|
+
const y = local.getFullYear();
|
|
136
|
+
const m = String(local.getMonth() + 1).padStart(2, '0');
|
|
137
|
+
const d = String(local.getDate()).padStart(2, '0');
|
|
138
|
+
return `${y}-${m}-${d}`;
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Allowed deduct values (15-second increments expressed as fractions of 1 hour)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
const VALID_DEDUCTS = new Set([0, 0.08, 0.17, 0.25, 0.33, 0.42, 0.5, 0.58, 0.67, 0.75, 0.83, 0.92, 1.0]);
|
|
144
|
+
let _chargeCodeRules = null;
|
|
145
|
+
function getChargeCodeRules() {
|
|
146
|
+
if (_chargeCodeRules)
|
|
147
|
+
return _chargeCodeRules;
|
|
148
|
+
try {
|
|
149
|
+
// Local override takes priority over shipped defaults
|
|
150
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? join(__dirname, '../../../..');
|
|
151
|
+
const localPath = join(pluginRoot, 'config', 'charge-code-rules.local.json');
|
|
152
|
+
const defaultPath = join(pluginRoot, 'config', 'charge-code-rules.json');
|
|
153
|
+
const configPath = existsSync(localPath) ? localPath : defaultPath;
|
|
154
|
+
_chargeCodeRules = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
_chargeCodeRules = []; // No config file found — no charge code limits enforced
|
|
158
|
+
}
|
|
159
|
+
return _chargeCodeRules;
|
|
160
|
+
}
|
|
161
|
+
/** Get the applicable hour limit for a charge code given a role category. */
|
|
162
|
+
function getChargeCodeLimit(codeName, roleCategory) {
|
|
163
|
+
const rules = getChargeCodeRules();
|
|
164
|
+
const rule = rules.find(r => r.charge_code_name === codeName && r.enabled);
|
|
165
|
+
if (!rule)
|
|
166
|
+
return null;
|
|
167
|
+
if (roleCategory === 'gestion' && rule.max_hours_gestion !== null)
|
|
168
|
+
return rule.max_hours_gestion;
|
|
169
|
+
if (roleCategory === 'dispatch' && rule.max_hours_dispatch !== null)
|
|
170
|
+
return rule.max_hours_dispatch;
|
|
171
|
+
return rule.max_hours_regular; // null means no limit
|
|
172
|
+
}
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// 8-rule validation engine
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
function validateEntries(entries, roleCategory = 'regular') {
|
|
177
|
+
const violations = [];
|
|
178
|
+
// Group by date for overlap and pause checks
|
|
179
|
+
const byDate = new Map();
|
|
180
|
+
for (const e of entries) {
|
|
181
|
+
const key = toDateKey(e.dateEntered);
|
|
182
|
+
const bucket = byDate.get(key) ?? [];
|
|
183
|
+
bucket.push(e);
|
|
184
|
+
byDate.set(key, bucket);
|
|
185
|
+
}
|
|
186
|
+
// Group by ISO-week + charge code for weekly cap checks
|
|
187
|
+
function isoWeek(dateStr) {
|
|
188
|
+
const d = new Date(dateStr);
|
|
189
|
+
const jan1 = new Date(d.getFullYear(), 0, 1);
|
|
190
|
+
const week = Math.ceil(((d.getTime() - jan1.getTime()) / 86400000 + jan1.getDay() + 1) / 7);
|
|
191
|
+
return `${d.getFullYear()}-W${String(week).padStart(2, '0')}`;
|
|
192
|
+
}
|
|
193
|
+
const weeklyChargeHours = new Map(); // "<week>:<chargeCode>" -> hours
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
// Per-entry rules
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
// Rule 1: 5-minute rounding
|
|
199
|
+
const startT = parseHHMM(entry.timeStart);
|
|
200
|
+
const endT = parseHHMM(entry.timeEnd);
|
|
201
|
+
if (startT && startT.minutes % 5 !== 0) {
|
|
202
|
+
violations.push({
|
|
203
|
+
rule: '5min_rounding',
|
|
204
|
+
severity: 'reject',
|
|
205
|
+
entry_id: entry.id,
|
|
206
|
+
message: `timeStart ${entry.timeStart} is not on a 5-minute boundary`,
|
|
207
|
+
details: { timeStart: entry.timeStart, minutes: startT.minutes },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (endT && endT.minutes % 5 !== 0) {
|
|
211
|
+
violations.push({
|
|
212
|
+
rule: '5min_rounding',
|
|
213
|
+
severity: 'reject',
|
|
214
|
+
entry_id: entry.id,
|
|
215
|
+
message: `timeEnd ${entry.timeEnd} is not on a 5-minute boundary`,
|
|
216
|
+
details: { timeEnd: entry.timeEnd, minutes: endT.minutes },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// Rule 2: deduction rounding
|
|
220
|
+
const deduct = round2(entry.hoursDeduct ?? 0);
|
|
221
|
+
if (!VALID_DEDUCTS.has(deduct)) {
|
|
222
|
+
violations.push({
|
|
223
|
+
rule: 'deduct_rounding',
|
|
224
|
+
severity: 'reject',
|
|
225
|
+
entry_id: entry.id,
|
|
226
|
+
message: `hoursDeduct ${deduct} is not a valid deduction value`,
|
|
227
|
+
details: { hoursDeduct: deduct, validValues: [...VALID_DEDUCTS] },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// Rule 4: missing notes
|
|
231
|
+
if ((entry.actualHours ?? 0) > 0 && !entry.notes?.trim()) {
|
|
232
|
+
violations.push({
|
|
233
|
+
rule: 'missing_notes',
|
|
234
|
+
severity: 'reject',
|
|
235
|
+
entry_id: entry.id,
|
|
236
|
+
message: 'Entry has hours but no notes',
|
|
237
|
+
details: { actualHours: entry.actualHours },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// Rule 6: overtime flag (warning only)
|
|
241
|
+
if (startT && endT) {
|
|
242
|
+
const startMin = startT.hours * 60 + startT.minutes;
|
|
243
|
+
const endMin = endT.hours * 60 + endT.minutes;
|
|
244
|
+
const beforeWork = startMin < 8 * 60; // before 08:00
|
|
245
|
+
const afterWork = endMin > 17 * 60; // after 17:00
|
|
246
|
+
if (beforeWork || afterWork) {
|
|
247
|
+
const wt = (entry.workType?.name ?? '').toLowerCase();
|
|
248
|
+
const isOvertime = wt.includes('overtime') || wt === 'ot' || wt.includes('majoré') || wt.includes('off-hours');
|
|
249
|
+
if (!isOvertime) {
|
|
250
|
+
violations.push({
|
|
251
|
+
rule: 'overtime_flag',
|
|
252
|
+
severity: 'warn',
|
|
253
|
+
entry_id: entry.id,
|
|
254
|
+
message: `Entry spans outside 08:00–17:00 but work type is "${wt || 'unset'}" (not overtime)`,
|
|
255
|
+
details: { timeStart: entry.timeStart, timeEnd: entry.timeEnd, workType: wt },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Rule 8: charge-code weekly limits (loaded from config/charge-code-rules.json)
|
|
261
|
+
const chargeCodeName = entry.chargeCode?.name;
|
|
262
|
+
if (chargeCodeName) {
|
|
263
|
+
const limit = getChargeCodeLimit(chargeCodeName, roleCategory);
|
|
264
|
+
if (limit !== null) {
|
|
265
|
+
const weekKey = `${isoWeek(toDateKey(entry.dateEntered))}:${chargeCodeName}`;
|
|
266
|
+
const accumulated = (weeklyChargeHours.get(weekKey) ?? 0) + (entry.actualHours ?? 0);
|
|
267
|
+
weeklyChargeHours.set(weekKey, accumulated);
|
|
268
|
+
if (accumulated > limit) {
|
|
269
|
+
violations.push({
|
|
270
|
+
rule: 'charge_code_limits',
|
|
271
|
+
severity: 'reject',
|
|
272
|
+
entry_id: entry.id,
|
|
273
|
+
message: `Charge code "${chargeCodeName}" exceeds weekly limit of ${limit}h for role "${roleCategory}" (accumulated: ${round2(accumulated)}h)`,
|
|
274
|
+
details: { chargeCode: chargeCodeName, limit, accumulated: round2(accumulated), roleCategory },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// -------------------------------------------------------------------------
|
|
281
|
+
// Per-day rules (overlap detection, pause rules)
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
for (const [date, dayEntries] of byDate) {
|
|
284
|
+
// Rule 3: overlap detection
|
|
285
|
+
// Sort by timeStart ascending
|
|
286
|
+
const sorted = [...dayEntries].sort((a, b) => {
|
|
287
|
+
const am = toMinutes(a.timeStart) ?? 0;
|
|
288
|
+
const bm = toMinutes(b.timeStart) ?? 0;
|
|
289
|
+
return am - bm;
|
|
290
|
+
});
|
|
291
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
292
|
+
const current = sorted[i];
|
|
293
|
+
const next = sorted[i + 1];
|
|
294
|
+
const currentEnd = toMinutes(current.timeEnd);
|
|
295
|
+
const nextStart = toMinutes(next.timeStart);
|
|
296
|
+
if (currentEnd !== null && nextStart !== null && currentEnd > nextStart) {
|
|
297
|
+
// Check if the overlap is accounted for by a compensatory deduction.
|
|
298
|
+
// The actual overlap is the time where both entries run simultaneously.
|
|
299
|
+
// If either entry's deduction covers that overlap, no double-billing.
|
|
300
|
+
const overlapMin = currentEnd - nextStart;
|
|
301
|
+
const currentDeductMin = (current.hoursDeduct ?? 0) * 60;
|
|
302
|
+
const nextDeductMin = (next.hoursDeduct ?? 0) * 60;
|
|
303
|
+
// If either entry's deduction covers the overlap duration (within
|
|
304
|
+
// 5-minute tolerance), the overlap is intentionally accounted for
|
|
305
|
+
const isCompensated = (currentDeductMin > 0 && currentDeductMin >= overlapMin - 5) ||
|
|
306
|
+
(nextDeductMin > 0 && nextDeductMin >= overlapMin - 5);
|
|
307
|
+
if (!isCompensated) {
|
|
308
|
+
violations.push({
|
|
309
|
+
rule: 'overlap_detection',
|
|
310
|
+
severity: 'reject',
|
|
311
|
+
entry_id: next.id,
|
|
312
|
+
message: `Entry #${next.id} starts at ${next.timeStart} before entry #${current.id} ends at ${current.timeEnd} on ${date}`,
|
|
313
|
+
details: {
|
|
314
|
+
date,
|
|
315
|
+
overlapWithEntryId: current.id,
|
|
316
|
+
conflictStart: next.timeStart,
|
|
317
|
+
conflictEnd: current.timeEnd,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Rule 7: pause rules — max 0.25h deduct per AM / PM period per day
|
|
324
|
+
// Exempt leave/vacation entries — full-day leave uses 08:00-17:00 with 1h
|
|
325
|
+
// deduct as the standard template; this is not a "pause".
|
|
326
|
+
const LEAVE_KEYWORDS = ['vacances', 'vacation', 'congé', 'conge', 'sick', 'holiday', 'pto', 'férié', 'ferie', 'leave', 'absence'];
|
|
327
|
+
const isLeaveEntry = (e) => {
|
|
328
|
+
const wt = (e.workType?.name ?? '').toLowerCase();
|
|
329
|
+
return LEAVE_KEYWORDS.some(kw => wt.includes(kw));
|
|
330
|
+
};
|
|
331
|
+
// Identify compensatory deductions: if entry A has a deduct and another
|
|
332
|
+
// entry B is nested inside A's time range with duration ≈ A's deduct,
|
|
333
|
+
// then A's deduct is compensatory (accounts for B), not a pause.
|
|
334
|
+
const isCompensatoryDeduct = (entry) => {
|
|
335
|
+
if ((entry.hoursDeduct ?? 0) <= 0)
|
|
336
|
+
return false;
|
|
337
|
+
const entryStart = toMinutes(entry.timeStart);
|
|
338
|
+
const entryEnd = toMinutes(entry.timeEnd);
|
|
339
|
+
if (entryStart === null || entryEnd === null)
|
|
340
|
+
return false;
|
|
341
|
+
const deductMin = (entry.hoursDeduct ?? 0) * 60;
|
|
342
|
+
// Check if any other entry on this day is nested inside this entry's range
|
|
343
|
+
for (const other of dayEntries) {
|
|
344
|
+
if (other.id === entry.id)
|
|
345
|
+
continue;
|
|
346
|
+
const otherStart = toMinutes(other.timeStart);
|
|
347
|
+
const otherEnd = toMinutes(other.timeEnd);
|
|
348
|
+
if (otherStart === null || otherEnd === null)
|
|
349
|
+
continue;
|
|
350
|
+
if (otherStart >= entryStart && otherEnd <= entryEnd) {
|
|
351
|
+
const otherDuration = otherEnd - otherStart;
|
|
352
|
+
if (Math.abs(deductMin - otherDuration) <= 5)
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
};
|
|
358
|
+
// Filter out leave entries and compensatory deductions before summing
|
|
359
|
+
const pauseEntries = dayEntries.filter(e => !isLeaveEntry(e) && !isCompensatoryDeduct(e));
|
|
360
|
+
const amEntries = pauseEntries.filter((e) => {
|
|
361
|
+
const t = parseHHMM(e.timeStart);
|
|
362
|
+
return t !== null && t.hours < 12;
|
|
363
|
+
});
|
|
364
|
+
const pmEntries = pauseEntries.filter((e) => {
|
|
365
|
+
const t = parseHHMM(e.timeStart);
|
|
366
|
+
return t !== null && t.hours >= 12;
|
|
367
|
+
});
|
|
368
|
+
const amDeduct = amEntries.reduce((s, e) => s + (e.hoursDeduct ?? 0), 0);
|
|
369
|
+
const pmDeduct = pmEntries.reduce((s, e) => s + (e.hoursDeduct ?? 0), 0);
|
|
370
|
+
if (round2(amDeduct) > 0.25) {
|
|
371
|
+
// Flag the last AM entry as the carrier of the violation
|
|
372
|
+
const lastAm = amEntries[amEntries.length - 1];
|
|
373
|
+
if (lastAm) {
|
|
374
|
+
violations.push({
|
|
375
|
+
rule: 'pause_rules',
|
|
376
|
+
severity: 'reject',
|
|
377
|
+
entry_id: lastAm.id,
|
|
378
|
+
message: `AM deductions for ${date} total ${round2(amDeduct)}h — maximum is 0.25h`,
|
|
379
|
+
details: { date, period: 'AM', totalDeduct: round2(amDeduct), limit: 0.25 },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (round2(pmDeduct) > 0.25) {
|
|
384
|
+
const lastPm = pmEntries[pmEntries.length - 1];
|
|
385
|
+
if (lastPm) {
|
|
386
|
+
violations.push({
|
|
387
|
+
rule: 'pause_rules',
|
|
388
|
+
severity: 'reject',
|
|
389
|
+
entry_id: lastPm.id,
|
|
390
|
+
message: `PM deductions for ${date} total ${round2(pmDeduct)}h — maximum is 0.25h`,
|
|
391
|
+
details: { date, period: 'PM', totalDeduct: round2(pmDeduct), limit: 0.25 },
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Note: Rule 5 (department_match) requires member profile data.
|
|
397
|
+
// The check is intentionally omitted here to avoid an extra API round-trip
|
|
398
|
+
// inside the validation loop; it is surfaced as a design-time warning instead.
|
|
399
|
+
// If the caller provides member department info in a future parameter, add it here.
|
|
400
|
+
return violations;
|
|
401
|
+
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// text helper
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
function text(t) {
|
|
406
|
+
return { content: [{ type: 'text', text: t }] };
|
|
407
|
+
}
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Handler
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
export async function handleValidationTool(toolName, args) {
|
|
412
|
+
const api = getAPI();
|
|
413
|
+
switch (toolName) {
|
|
414
|
+
// -----------------------------------------------------------------------
|
|
415
|
+
case 'cw_validate_timesheets': {
|
|
416
|
+
const memberIdentifier = String(args['member_identifier'] ?? '').trim();
|
|
417
|
+
const startDate = String(args['start_date'] ?? '').trim();
|
|
418
|
+
const endDate = String(args['end_date'] ?? '').trim();
|
|
419
|
+
if (!memberIdentifier || !startDate || !endDate) {
|
|
420
|
+
return text('Error: member_identifier, start_date, and end_date are all required.');
|
|
421
|
+
}
|
|
422
|
+
// Basic ISO date format check
|
|
423
|
+
const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
|
|
424
|
+
if (!isoDateRe.test(startDate) || !isoDateRe.test(endDate)) {
|
|
425
|
+
return text('Error: start_date and end_date must be in YYYY-MM-DD format.');
|
|
426
|
+
}
|
|
427
|
+
// Fetch time entries
|
|
428
|
+
let entries;
|
|
429
|
+
try {
|
|
430
|
+
const conditions = `member/identifier='${memberIdentifier}' AND dateEntered>=[${startDate}] AND dateEntered<=[${endDate}]`;
|
|
431
|
+
const rawEntries = await api.paginatedFetch('/time/entries', conditions, undefined, 1000);
|
|
432
|
+
entries = rawEntries.items;
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
436
|
+
return text(`Error fetching time entries: ${msg}`);
|
|
437
|
+
}
|
|
438
|
+
if (entries.length === 0) {
|
|
439
|
+
return text(`No time entries found for member "${memberIdentifier}" between ${startDate} and ${endDate}.`);
|
|
440
|
+
}
|
|
441
|
+
const violations = validateEntries(entries);
|
|
442
|
+
const rejects = violations.filter((v) => v.severity === 'reject');
|
|
443
|
+
const warns = violations.filter((v) => v.severity === 'warn');
|
|
444
|
+
const passed = rejects.length === 0;
|
|
445
|
+
const result = {
|
|
446
|
+
member: memberIdentifier,
|
|
447
|
+
period: `${startDate} to ${endDate}`,
|
|
448
|
+
totalEntries: entries.length,
|
|
449
|
+
violations,
|
|
450
|
+
passed,
|
|
451
|
+
};
|
|
452
|
+
// Build human-readable summary
|
|
453
|
+
const lines = [
|
|
454
|
+
`Validation result for ${memberIdentifier} (${startDate} to ${endDate})`,
|
|
455
|
+
`Total entries : ${entries.length}`,
|
|
456
|
+
`Status : ${passed ? 'PASSED' : 'FAILED'}`,
|
|
457
|
+
`Violations : ${rejects.length} reject${rejects.length !== 1 ? 's' : ''}, ${warns.length} warning${warns.length !== 1 ? 's' : ''}`,
|
|
458
|
+
'',
|
|
459
|
+
];
|
|
460
|
+
if (violations.length === 0) {
|
|
461
|
+
lines.push('All entries passed validation.');
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
if (rejects.length > 0) {
|
|
465
|
+
lines.push('--- REJECTS (must be corrected) ---');
|
|
466
|
+
for (const v of rejects) {
|
|
467
|
+
lines.push(` Entry #${v.entry_id} [${v.rule}]: ${v.message}`);
|
|
468
|
+
}
|
|
469
|
+
lines.push('');
|
|
470
|
+
}
|
|
471
|
+
if (warns.length > 0) {
|
|
472
|
+
lines.push('--- WARNINGS (advisory) ---');
|
|
473
|
+
for (const v of warns) {
|
|
474
|
+
lines.push(` Entry #${v.entry_id} [${v.rule}]: ${v.message}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
lines.push('');
|
|
479
|
+
lines.push('--- Full JSON result ---');
|
|
480
|
+
lines.push(JSON.stringify(result, null, 2));
|
|
481
|
+
return text(lines.join('\n'));
|
|
482
|
+
}
|
|
483
|
+
// -----------------------------------------------------------------------
|
|
484
|
+
case 'cw_accrual_balance': {
|
|
485
|
+
const memberIdentifier = String(args['member_identifier'] ?? '').trim();
|
|
486
|
+
if (!memberIdentifier) {
|
|
487
|
+
return text('Error: member_identifier is required.');
|
|
488
|
+
}
|
|
489
|
+
let accruals;
|
|
490
|
+
try {
|
|
491
|
+
const raw = await api.request({
|
|
492
|
+
path: '/time/accruals',
|
|
493
|
+
method: 'GET',
|
|
494
|
+
params: { conditions: `member/identifier='${memberIdentifier}'` },
|
|
495
|
+
});
|
|
496
|
+
accruals = Array.isArray(raw) ? raw : [];
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
500
|
+
return text(`Error fetching accruals: ${msg}`);
|
|
501
|
+
}
|
|
502
|
+
if (accruals.length === 0) {
|
|
503
|
+
return text(`No accrual records found for member "${memberIdentifier}".`);
|
|
504
|
+
}
|
|
505
|
+
const lines = [
|
|
506
|
+
`Accrual balances for ${memberIdentifier}`,
|
|
507
|
+
'',
|
|
508
|
+
`${'Type'.padEnd(22)} ${'Current Bal'.padStart(12)} ${'YTD Hours'.padStart(12)} ${'Available'.padStart(12)}`,
|
|
509
|
+
`${'-'.repeat(22)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(12)}`,
|
|
510
|
+
];
|
|
511
|
+
for (const raw of accruals) {
|
|
512
|
+
const record = raw;
|
|
513
|
+
const typeName = record.accrualType?.name ?? 'Unknown';
|
|
514
|
+
const current = round2(record.currentBalance ?? 0);
|
|
515
|
+
const ytd = round2(record.ytdHours ?? 0);
|
|
516
|
+
const available = round2(record.availableHours ?? record.currentBalance ?? 0);
|
|
517
|
+
lines.push(`${typeName.padEnd(22)} ${String(current).padStart(12)} ${String(ytd).padStart(12)} ${String(available).padStart(12)}`);
|
|
518
|
+
}
|
|
519
|
+
lines.push('');
|
|
520
|
+
lines.push('--- Raw JSON ---');
|
|
521
|
+
lines.push(JSON.stringify(accruals, null, 2));
|
|
522
|
+
return text(lines.join('\n'));
|
|
523
|
+
}
|
|
524
|
+
// -----------------------------------------------------------------------
|
|
525
|
+
case 'cw_approve_timesheets': {
|
|
526
|
+
return await approveTimesheets(args, api);
|
|
527
|
+
}
|
|
528
|
+
// -----------------------------------------------------------------------
|
|
529
|
+
default:
|
|
530
|
+
return text(`Unknown validation tool: ${toolName}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
async function approveTimesheets(args, api) {
|
|
534
|
+
const startDate = String(args['start_date'] ?? '').trim();
|
|
535
|
+
const endDate = String(args['end_date'] ?? '').trim();
|
|
536
|
+
const memberFilter = args['member_identifier'] ? String(args['member_identifier']).trim() : null;
|
|
537
|
+
const dryRun = args['dry_run'] !== false; // Default true — safe by default
|
|
538
|
+
if (!startDate || !endDate) {
|
|
539
|
+
return text('Error: start_date and end_date are required (YYYY-MM-DD).');
|
|
540
|
+
}
|
|
541
|
+
const lines = [];
|
|
542
|
+
lines.push(`## Timesheet Approval Workflow`);
|
|
543
|
+
lines.push(`Period: ${startDate} to ${endDate}`);
|
|
544
|
+
lines.push(`Mode: ${dryRun ? 'DRY RUN (no changes will be made)' : 'LIVE — will approve/reject in ConnectWise'}`);
|
|
545
|
+
if (memberFilter)
|
|
546
|
+
lines.push(`Member filter: ${memberFilter}`);
|
|
547
|
+
lines.push('');
|
|
548
|
+
// Step 1: Fetch timesheets in the period
|
|
549
|
+
let timesheetConditions = `dateStart>=[${startDate}] AND dateStart<=[${endDate}]`;
|
|
550
|
+
if (memberFilter) {
|
|
551
|
+
timesheetConditions += ` AND member/identifier='${memberFilter}'`;
|
|
552
|
+
}
|
|
553
|
+
// Only process Open or PendingApproval timesheets
|
|
554
|
+
// Note: CW timesheet `status` is a flat string, not an object — no /name accessor
|
|
555
|
+
timesheetConditions += ` AND (status='Open' OR status='PendingApproval')`;
|
|
556
|
+
let timesheets;
|
|
557
|
+
try {
|
|
558
|
+
const raw = await api.paginatedFetch('/time/sheets', timesheetConditions, undefined, 500);
|
|
559
|
+
timesheets = raw.items;
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
563
|
+
return text(`Error fetching timesheets: ${msg}`);
|
|
564
|
+
}
|
|
565
|
+
if (timesheets.length === 0) {
|
|
566
|
+
return text(`No Open or PendingApproval timesheets found for ${memberFilter ?? 'any member'} between ${startDate} and ${endDate}.`);
|
|
567
|
+
}
|
|
568
|
+
lines.push(`Found ${timesheets.length} timesheet(s) to process.`);
|
|
569
|
+
lines.push('');
|
|
570
|
+
const fetchResults = await Promise.all(timesheets.map(async (ts) => {
|
|
571
|
+
const memberId = ts.member?.identifier ?? 'unknown';
|
|
572
|
+
try {
|
|
573
|
+
const entryConds = `member/identifier='${memberId}' AND dateEntered>=[${ts.dateStart}] AND dateEntered<=[${ts.dateEnd}]`;
|
|
574
|
+
const raw = await api.paginatedFetch('/time/entries', entryConds, undefined, 1000);
|
|
575
|
+
return { ts, entries: raw.items, error: null };
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
579
|
+
return { ts, entries: null, error: msg };
|
|
580
|
+
}
|
|
581
|
+
}));
|
|
582
|
+
const validated = fetchResults.map(fr => {
|
|
583
|
+
if (!fr.entries || fr.entries.length === 0) {
|
|
584
|
+
return { ...fr, violations: [], rejects: [], warns: [] };
|
|
585
|
+
}
|
|
586
|
+
const violations = validateEntries(fr.entries);
|
|
587
|
+
return {
|
|
588
|
+
...fr,
|
|
589
|
+
violations,
|
|
590
|
+
rejects: violations.filter(v => v.severity === 'reject'),
|
|
591
|
+
warns: violations.filter(v => v.severity === 'warn'),
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
lines.push(`Fetched & validated ${timesheets.length} timesheets in parallel.`);
|
|
595
|
+
lines.push('');
|
|
596
|
+
// Step 4: PARALLEL APPROVE/REJECT — execute actions for all timesheets at once
|
|
597
|
+
const results = await Promise.all(validated.map(async (v) => {
|
|
598
|
+
const memberId = v.ts.member?.identifier ?? 'unknown';
|
|
599
|
+
const memberName = v.ts.member?.name ?? memberId;
|
|
600
|
+
const tsId = v.ts.id;
|
|
601
|
+
// Error during fetch
|
|
602
|
+
if (v.error) {
|
|
603
|
+
return { member: memberId, timesheetId: tsId, action: 'ERROR', rejectCount: 0, warnCount: 0, entryCount: 0, rejectedEntryIds: [], message: v.error };
|
|
604
|
+
}
|
|
605
|
+
// No entries
|
|
606
|
+
if (!v.entries || v.entries.length === 0) {
|
|
607
|
+
return { member: memberId, timesheetId: tsId, action: 'SKIPPED', rejectCount: 0, warnCount: 0, entryCount: 0, rejectedEntryIds: [], message: 'No entries' };
|
|
608
|
+
}
|
|
609
|
+
const entryCount = v.entries.length;
|
|
610
|
+
if (v.rejects.length > 0) {
|
|
611
|
+
// REJECT path
|
|
612
|
+
const entryIdsToReject = [...new Set(v.rejects.map(viol => viol.entry_id))];
|
|
613
|
+
if (!dryRun) {
|
|
614
|
+
// Reject individual entries IN PARALLEL
|
|
615
|
+
const rejectionResults = await Promise.all(entryIdsToReject.map(async (entryId) => {
|
|
616
|
+
const entryViolations = v.rejects.filter(viol => viol.entry_id === entryId);
|
|
617
|
+
const comment = 'Automated rejection: ' + entryViolations.map(viol => `[${viol.rule}] ${viol.message}`).join('; ');
|
|
618
|
+
try {
|
|
619
|
+
await api.requestWithRetry({
|
|
620
|
+
path: `/time/entries/${entryId}/reject`,
|
|
621
|
+
method: 'POST',
|
|
622
|
+
body: { approvalType: 'Tier1Update', comment },
|
|
623
|
+
});
|
|
624
|
+
return { entryId, success: true };
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
return { entryId, success: false };
|
|
628
|
+
}
|
|
629
|
+
}));
|
|
630
|
+
const rejectedIds = rejectionResults.filter(r => r.success).map(r => r.entryId);
|
|
631
|
+
// Then reject the timesheet (must come AFTER entries)
|
|
632
|
+
try {
|
|
633
|
+
await api.requestWithRetry({
|
|
634
|
+
path: `/time/sheets/${tsId}/reject`,
|
|
635
|
+
method: 'POST',
|
|
636
|
+
body: { approvalType: 'Tier1Update' },
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
catch { /* logged in summary */ }
|
|
640
|
+
return { member: memberId, timesheetId: tsId, action: 'REJECTED', rejectCount: v.rejects.length, warnCount: v.warns.length, entryCount, rejectedEntryIds: rejectedIds, message: `${rejectedIds.length} entries rejected` };
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
return { member: memberId, timesheetId: tsId, action: 'REJECTED', rejectCount: v.rejects.length, warnCount: v.warns.length, entryCount, rejectedEntryIds: entryIdsToReject, message: 'DRY RUN' };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
// APPROVE path
|
|
648
|
+
if (!dryRun) {
|
|
649
|
+
try {
|
|
650
|
+
await api.requestWithRetry({
|
|
651
|
+
path: `/time/sheets/${tsId}/approve`,
|
|
652
|
+
method: 'POST',
|
|
653
|
+
body: { approvalType: 'Tier1Update' },
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
catch { /* logged in summary */ }
|
|
657
|
+
return { member: memberId, timesheetId: tsId, action: 'APPROVED', rejectCount: 0, warnCount: v.warns.length, entryCount, rejectedEntryIds: [], message: v.warns.length > 0 ? `Approved with ${v.warns.length} warnings` : 'Clean approval' };
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
return { member: memberId, timesheetId: tsId, action: 'APPROVED', rejectCount: 0, warnCount: v.warns.length, entryCount, rejectedEntryIds: [], message: 'DRY RUN' };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}));
|
|
664
|
+
// Step 5: Build human-readable output from results
|
|
665
|
+
for (const r of results) {
|
|
666
|
+
const memberName = timesheets.find(ts => ts.id === r.timesheetId)?.member?.name ?? r.member;
|
|
667
|
+
lines.push(`### ${memberName} (timesheet #${r.timesheetId})`);
|
|
668
|
+
lines.push(` Entries: ${r.entryCount} | Rejects: ${r.rejectCount} | Warnings: ${r.warnCount}`);
|
|
669
|
+
lines.push(` Decision: **${r.action}** — ${r.message}`);
|
|
670
|
+
// Show violation details
|
|
671
|
+
const v = validated.find(v => v.ts.id === r.timesheetId);
|
|
672
|
+
if (v && v.rejects.length > 0) {
|
|
673
|
+
for (const viol of v.rejects) {
|
|
674
|
+
lines.push(` - Entry #${viol.entry_id} [${viol.rule}]: ${viol.message}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (v && v.warns.length > 0) {
|
|
678
|
+
for (const viol of v.warns) {
|
|
679
|
+
lines.push(` ⚠ Entry #${viol.entry_id} [${viol.rule}]: ${viol.message}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
lines.push('');
|
|
683
|
+
}
|
|
684
|
+
// Summary
|
|
685
|
+
const approved = results.filter(r => r.action === 'APPROVED').length;
|
|
686
|
+
const rejected = results.filter(r => r.action === 'REJECTED').length;
|
|
687
|
+
const skipped = results.filter(r => r.action === 'SKIPPED').length;
|
|
688
|
+
const errors = results.filter(r => r.action === 'ERROR').length;
|
|
689
|
+
lines.push('---');
|
|
690
|
+
lines.push('## Summary');
|
|
691
|
+
lines.push(`| Status | Count |`);
|
|
692
|
+
lines.push(`|--------|-------|`);
|
|
693
|
+
lines.push(`| Approved | ${approved} |`);
|
|
694
|
+
lines.push(`| Rejected | ${rejected} |`);
|
|
695
|
+
lines.push(`| Skipped | ${skipped} |`);
|
|
696
|
+
lines.push(`| Errors | ${errors} |`);
|
|
697
|
+
lines.push(`| **Total** | **${results.length}** |`);
|
|
698
|
+
if (dryRun) {
|
|
699
|
+
lines.push('');
|
|
700
|
+
lines.push('> **This was a DRY RUN.** No changes were made in ConnectWise.');
|
|
701
|
+
lines.push('> To execute for real, run again with `dry_run: false`.');
|
|
702
|
+
}
|
|
703
|
+
return text(lines.join('\n'));
|
|
704
|
+
}
|
|
705
|
+
//# sourceMappingURL=validation.js.map
|