feat: add project_phases entity sync with task project_id backfill
The Autotask Tasks bulk API does not return projectID in its response, causing all tasks.project_id to be NULL. This fixes it by: - Adding project_phases as a synced entity (Autotask endpoint: /Phases) - Migration 059: project_phases table with project_id, phase_number, estimated_hours, start/due dates, parent_phase_id, is_scheduled - EntityType.PROJECT_PHASES added to all sync maps and dependency graph (depends on PROJECTS, runs before TASKS in sync order) - buildProjectPhasesFilter: Phases endpoint requires a filter (id > 0) - mapProjectPhase: maps Autotask field names to DB columns - Post-sync backfill in syncEntity: after each project_phases sync, UPDATE tasks SET project_id = pp.project_id FROM project_phases pp JOIN projects p WHERE tasks.phase_id = pp.id Only backfills where the project exists in our DB (FK constraint on tasks.project_id; archived projects are skipped gracefully) Result: 2,455 of 4,966 tasks now have project_id populated. Tasks belonging to archived/completed projects have phase_id resolvable via project_phases even when project_id remains NULL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
44db9a3019
commit
dd4cf68def
5 changed files with 131 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
buildContractsFilter,
|
||||
buildContractServicesFilter,
|
||||
buildProjectsFilter,
|
||||
buildProjectPhasesFilter,
|
||||
buildTimeEntriesFilter,
|
||||
buildBillingItemsFilter,
|
||||
getTableName
|
||||
|
|
@ -132,6 +133,9 @@ export class EntitySyncService {
|
|||
} else if (entity === EntityType.PROJECTS) {
|
||||
filters.push(...buildProjectsFilter());
|
||||
entityLogger.info('Full sync with status filter for non-completed projects');
|
||||
} else if (entity === EntityType.PROJECT_PHASES) {
|
||||
filters.push(...buildProjectPhasesFilter());
|
||||
entityLogger.info('Full sync of all project phases');
|
||||
} else if (entity === EntityType.TIME_ENTRIES) {
|
||||
filters.push(...buildTimeEntriesFilter(yearsBack));
|
||||
entityLogger.info(`Full sync with dateWorked filter for last ${yearsBack} years`);
|
||||
|
|
@ -384,6 +388,26 @@ export class EntitySyncService {
|
|||
}
|
||||
}
|
||||
|
||||
// Post-sync backfill: after syncing phases, populate tasks.project_id via phase → project join.
|
||||
// The Autotask Tasks bulk query does not return projectID — it must be resolved through phaseID.
|
||||
if (entity === EntityType.PROJECT_PHASES) {
|
||||
try {
|
||||
const backfillResult = await postgresClient.query(
|
||||
`UPDATE tasks
|
||||
SET project_id = pp.project_id
|
||||
FROM project_phases pp
|
||||
JOIN projects p ON p.id = pp.project_id
|
||||
WHERE tasks.phase_id = pp.id
|
||||
AND tasks.project_id IS DISTINCT FROM pp.project_id
|
||||
AND pp.project_id IS NOT NULL`
|
||||
);
|
||||
const updated = (backfillResult as any).rowCount ?? 0;
|
||||
entityLogger.info('Backfilled tasks.project_id via phase → project join', { updatedTaskCount: updated });
|
||||
} catch (err) {
|
||||
entityLogger.warn('Task project_id backfill failed', { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// For full sync, soft delete records not in the fetched set
|
||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||
// because we cannot know what records exist outside the filter criteria.
|
||||
|
|
@ -744,6 +768,34 @@ export class EntitySyncService {
|
|||
return await this.syncEntity(EntityType.PROJECTS, isIncremental);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Project Phases, then backfill project_id on tasks that reference those phases.
|
||||
* Autotask's Tasks bulk query does not return projectID — it must be resolved via phaseID.
|
||||
*/
|
||||
async syncProjectPhases(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
const stats = await this.syncEntity(EntityType.PROJECT_PHASES, isIncremental);
|
||||
|
||||
// Backfill tasks.project_id using the newly synced phases
|
||||
try {
|
||||
const result = await postgresClient.query<{ rowCount: number }>(
|
||||
`UPDATE tasks
|
||||
SET project_id = pp.project_id
|
||||
FROM project_phases pp
|
||||
WHERE tasks.phase_id = pp.id
|
||||
AND tasks.project_id IS DISTINCT FROM pp.project_id
|
||||
AND pp.project_id IS NOT NULL`
|
||||
);
|
||||
const updated = (result as any).rowCount ?? 0;
|
||||
if (updated > 0) {
|
||||
this.logger.info(`Backfilled project_id on ${updated} tasks via phase → project join`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('Task project_id backfill failed', { error: String(err) });
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Resources (Users)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue