Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/services/bc/bcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
TimesheetDisplayStatus,
PaginatedResponse,
} from '@/types';
import { getTimesheetDisplayStatus } from '@/utils';
import { getTimesheetDisplayStatus, decodeBCEnum } from '@/utils';

const BC_BASE_URL =
process.env.NEXT_PUBLIC_BC_BASE_URL || 'https://api.businesscentral.dynamics.com/v2.0';
Expand All @@ -41,6 +41,18 @@ const THYME_API_VERSION = 'v1.0';
// Valid TimeSheetStatus values for whitelist validation
const VALID_TIMESHEET_STATUSES = ['Open', 'Submitted', 'Rejected', 'Approved', 'Posted'] as const;

// Known planning-line enum values, used to narrow decoded BC strings back to
// the typed unions. An unrecognized decode falls back to the original value
// rather than being blindly cast, so unexpected BC enums aren't masked.
const PLANNING_LINE_TYPES = ['Resource', 'Item', 'G/L Account'] as const;
const PLANNING_LINE_LINE_TYPES = ['Budget', 'Billable', 'Both Budget and Billable'] as const;

// Narrow a decoded enum string to one of the allowed values, falling back to
// the original (still-typed) value when the decode isn't a recognized member.
function narrowEnum<T extends string>(decoded: string, allowed: readonly T[], fallback: T): T {
return (allowed as readonly string[]).includes(decoded) ? (decoded as T) : fallback;
}

// Available environments to query
const BC_ENVIRONMENTS: BCEnvironmentType[] = ['sandbox', 'production'];

Expand Down Expand Up @@ -466,6 +478,18 @@ class BusinessCentralClient {
return this.fetch<BCJobTask>(`/jobTasks(${jobTaskId})`);
}

// Decode BC's OData enum encoding on planning-line fields so downstream code
// can compare against the plain values. BC serializes named enums like
// `type` "G/L Account" as "G_x002F_L_x0020_Account" and `lineType`
// "Both Budget and Billable" as "Both_x0020_Budget_x0020_and_x0020_Billable".
private normalizePlanningLines(lines: BCJobPlanningLine[]): BCJobPlanningLine[] {
return lines.map((line) => ({
...line,
type: narrowEnum(decodeBCEnum(line.type), PLANNING_LINE_TYPES, line.type),
lineType: narrowEnum(decodeBCEnum(line.lineType), PLANNING_LINE_LINE_TYPES, line.lineType),
}));
}

// Job Planning Lines - requires Thyme BC Extension v1.6.0+
// Provides budget/planned hours data for projects
async getJobPlanningLines(jobNumber: string): Promise<BCJobPlanningLine[]> {
Expand Down Expand Up @@ -502,7 +526,7 @@ class BusinessCentralClient {
}

const data = await response.json();
return data.value || [];
return this.normalizePlanningLines(data.value || []);
} catch (error) {
console.error('[BC API] Error fetching job planning lines:', error);
return [];
Expand Down Expand Up @@ -592,7 +616,7 @@ class BusinessCentralClient {
}

const data = await response.json();
return data.value || [];
return this.normalizePlanningLines(data.value || []);
} catch (error) {
console.error('[BC API] Error fetching job planning lines for week:', error);
return [];
Expand Down
39 changes: 39 additions & 0 deletions src/utils/unitConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,45 @@ export function formatHours(hours: number): string {
return parseFloat(hours.toFixed(2)).toString();
}

/**
* Decode BC's OData enum-value encoding.
*
* BC's OData JSON serializer URL-encodes any character that isn't valid in an
* identifier as `_xHHHH_`, where HHHH is the UTF-16 code unit in hex. So the
* planning-line type "G/L Account" arrives as "G_x002F_L_x0020_Account"
* (`/` → `_x002F_`, space → `_x0020_`), and the lineType "Both Budget and
* Billable" arrives as "Both_x0020_Budget_x0020_and_x0020_Billable".
*
* A literal underscore that would otherwise start an escape is itself encoded
* as `_x005F_`, so a source value that genuinely contains `_x0020_` arrives as
* `_x005F_x0020_`. We scan left-to-right and consume each `_xHHHH_` as a single
* unit, so the `_x005F_` decodes to a literal `_` and the following `x0020_` is
* left untouched (rather than being re-read as a space).
*
* Decoding once at the data boundary lets all downstream comparisons use the
* plain, human-readable values (e.g. `type === 'G/L Account'`).
*/
export function decodeBCEnum(value: string | undefined): string {
if (!value) return '';
// Sticky regex anchors each match at the current scan position, so we never
// re-scan already-decoded output or share a delimiter `_` between escapes.
const escape = /_x([0-9A-Fa-f]{4})_/y;
let result = '';
let i = 0;
while (i < value.length) {
escape.lastIndex = i;
const match = escape.exec(value);
if (match) {
result += String.fromCharCode(parseInt(match[1], 16));
i += match[0].length;
} else {
result += value[i];
i += 1;
}
}
return result;
}

/**
* Whether a planning line's `lineType` counts as a Budget line.
*
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/utils/unitConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isResourceDayBased,
isBudgetPlanningLine,
sumPlannedHours,
decodeBCEnum,
} from '@/utils/unitConversion';
import type { BCJobPlanningLine, BCResourceUnitOfMeasure } from '@/types';

Expand Down Expand Up @@ -201,6 +202,41 @@ describe('unitConversion', () => {
});
});

describe('decodeBCEnum', () => {
it('decodes the "G/L Account" type encoded by BC as "G_x002F_L_x0020_Account"', () => {
// Confirmed against the live /jobPlanningLines response for PR00100 task 9999.
expect(decodeBCEnum('G_x002F_L_x0020_Account')).toBe('G/L Account');
});

it('decodes the "Both Budget and Billable" lineType', () => {
expect(decodeBCEnum('Both_x0020_Budget_x0020_and_x0020_Billable')).toBe(
'Both Budget and Billable'
);
});

it('leaves already-plain values unchanged', () => {
expect(decodeBCEnum('Resource')).toBe('Resource');
expect(decodeBCEnum('Budget')).toBe('Budget');
expect(decodeBCEnum('G/L Account')).toBe('G/L Account');
});

it('treats _x005F_ as a literal underscore and leaves the following escape intact', () => {
// A source value that genuinely contains "_x0020_" is encoded as
// "_x005F_x0020_"; it must decode back to the literal "_x0020_", not " ".
expect(decodeBCEnum('_x005F_x0020_')).toBe('_x0020_');
});

it('decodes adjacent escapes independently', () => {
// "//" → each "/" encoded separately as "_x002F_".
expect(decodeBCEnum('_x002F__x002F_')).toBe('//');
});

it('returns an empty string for undefined or empty input', () => {
expect(decodeBCEnum(undefined)).toBe('');
expect(decodeBCEnum('')).toBe('');
});
});

describe('sumPlannedHours', () => {
it('sums Resource Budget lines, converting DAY quantities to hours', () => {
const map = buildUOMConversionMap([makeUOM('R001', 'HOUR', 7.5)]);
Expand Down
Loading