refactor: complete v2 refactoring plan (Phases 1-5)
Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract findUniqueOrThrow helper (19 routers), shared Prisma select constants, useInvalidatePlanningViews hook, status badge consolidation, composite DB indexes. Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel, TimelineProjectPanel; split 28-dep useMemo into 3 focused memos. TimelineView.tsx reduced from 1,903 to 538 lines. Phase 3 — Query Performance: server-side filtering for getEntriesView, remove availability from timeline resource select, SSE event debouncing (50ms batch window). Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor components. EstimateWorkspaceClient 1,298→306 lines, EstimateWorkspaceDraftEditor 1,205→581 lines. Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573 lines), extract shared pagination helper with 11 tests. All tests pass: 209 API, 254 engine, 67 application. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
import {
|
||||
createWeekdayAvailabilityFromFte,
|
||||
normalizeDispoRoleToken,
|
||||
} from "@planarchy/shared";
|
||||
import type { TxClient, MergedStagedResource } from "./commit-dispo-batch-types.js";
|
||||
import { deriveRoleTokens } from "./shared.js";
|
||||
|
||||
function asNullableString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function normalizeFallbackRoleName(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function mergeScalar<T>(
|
||||
current: T | null,
|
||||
incoming: T | null | undefined,
|
||||
): T | null {
|
||||
return incoming ?? current;
|
||||
}
|
||||
|
||||
export function inferRoleNameFromResource(resource: MergedStagedResource): string | null {
|
||||
const explicitRoleName = Array.from(resource.roleTokens)
|
||||
.map((token) => normalizeDispoRoleToken(token))
|
||||
.find((roleName): roleName is string => Boolean(roleName));
|
||||
if (explicitRoleName) {
|
||||
return explicitRoleName;
|
||||
}
|
||||
|
||||
const derivedRoleName = deriveRoleTokens(
|
||||
resource.chapter,
|
||||
asNullableString(resource.rawPayload.department),
|
||||
asNullableString(resource.rawPayload.mainSkillset),
|
||||
asNullableString(resource.rawPayload.sapOrgUnitLevelSix),
|
||||
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven),
|
||||
asNullableString(resource.rawPayload.sapEmployeeName),
|
||||
)
|
||||
.map((token) => normalizeDispoRoleToken(token))
|
||||
.find((roleName): roleName is string => Boolean(roleName));
|
||||
if (derivedRoleName) {
|
||||
return derivedRoleName;
|
||||
}
|
||||
|
||||
if (resource.chapter === "Art Direction") {
|
||||
return "Art Director";
|
||||
}
|
||||
if (resource.chapter === "Project Management") {
|
||||
return "Project Manager";
|
||||
}
|
||||
|
||||
const fallbackRoleLabel =
|
||||
asNullableString(resource.rawPayload.department) ??
|
||||
asNullableString(resource.rawPayload.mainSkillset) ??
|
||||
asNullableString(resource.chapter) ??
|
||||
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven) ??
|
||||
asNullableString(resource.rawPayload.sapOrgUnitLevelSix);
|
||||
|
||||
return fallbackRoleLabel ? normalizeFallbackRoleName(fallbackRoleLabel) : null;
|
||||
}
|
||||
|
||||
export function mergeStagedResources(
|
||||
rows: Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>,
|
||||
): Map<string, MergedStagedResource> {
|
||||
const sourcePriority = new Map([
|
||||
["CHARGEABILITY", 1],
|
||||
["ROSTER", 2],
|
||||
]);
|
||||
const ordered = [...rows].sort(
|
||||
(left, right) =>
|
||||
(sourcePriority.get(left.sourceKind) ?? 0) - (sourcePriority.get(right.sourceKind) ?? 0),
|
||||
);
|
||||
const merged = new Map<string, MergedStagedResource>();
|
||||
|
||||
for (const row of ordered) {
|
||||
const existing = merged.get(row.canonicalExternalId);
|
||||
const rawPayload = asObject(row.rawPayload);
|
||||
|
||||
if (!existing) {
|
||||
merged.set(row.canonicalExternalId, {
|
||||
availability: row.availability ?? null,
|
||||
canonicalExternalId: row.canonicalExternalId,
|
||||
chapter: row.chapter ?? null,
|
||||
chargeabilityTarget: row.chargeabilityTarget ?? null,
|
||||
clientUnitName: row.clientUnitName ?? null,
|
||||
countryCode: row.countryCode ?? null,
|
||||
displayName: row.displayName ?? null,
|
||||
email: row.email ?? null,
|
||||
fte: row.fte ?? null,
|
||||
lcrCents: row.lcrCents ?? null,
|
||||
managementLevelGroupName: row.managementLevelGroupName ?? null,
|
||||
managementLevelName: row.managementLevelName ?? null,
|
||||
metroCityName: row.metroCityName ?? null,
|
||||
rawPayload,
|
||||
resourceType: row.resourceType ?? null,
|
||||
roleTokens: new Set(row.roleTokens),
|
||||
sourceKinds: [row.sourceKind],
|
||||
ucrCents: row.ucrCents ?? null,
|
||||
vacationDaysPerYear: isFiniteNumber(rawPayload.vacationDaysPerYear)
|
||||
? rawPayload.vacationDaysPerYear
|
||||
: null,
|
||||
warnings: [...row.warnings],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.availability = mergeScalar(existing.availability, row.availability);
|
||||
existing.chapter = mergeScalar(existing.chapter, row.chapter);
|
||||
existing.chargeabilityTarget = mergeScalar(existing.chargeabilityTarget, row.chargeabilityTarget);
|
||||
existing.clientUnitName = mergeScalar(existing.clientUnitName, row.clientUnitName);
|
||||
existing.countryCode = mergeScalar(existing.countryCode, row.countryCode);
|
||||
existing.displayName = mergeScalar(existing.displayName, row.displayName);
|
||||
existing.email = mergeScalar(existing.email, row.email);
|
||||
existing.fte = mergeScalar(existing.fte, row.fte);
|
||||
existing.lcrCents = mergeScalar(existing.lcrCents, row.lcrCents);
|
||||
existing.managementLevelGroupName = mergeScalar(
|
||||
existing.managementLevelGroupName,
|
||||
row.managementLevelGroupName,
|
||||
);
|
||||
existing.managementLevelName = mergeScalar(existing.managementLevelName, row.managementLevelName);
|
||||
existing.metroCityName = mergeScalar(existing.metroCityName, row.metroCityName);
|
||||
existing.resourceType = mergeScalar(existing.resourceType, row.resourceType);
|
||||
existing.ucrCents = mergeScalar(existing.ucrCents, row.ucrCents);
|
||||
if (existing.availability === null && row.availability !== null) {
|
||||
existing.availability = row.availability;
|
||||
}
|
||||
for (const roleToken of row.roleTokens) {
|
||||
existing.roleTokens.add(roleToken);
|
||||
}
|
||||
existing.sourceKinds.push(row.sourceKind);
|
||||
existing.warnings.push(...row.warnings);
|
||||
existing.rawPayload = {
|
||||
...existing.rawPayload,
|
||||
...rawPayload,
|
||||
};
|
||||
if (isFiniteNumber(rawPayload.vacationDaysPerYear)) {
|
||||
existing.vacationDaysPerYear = rawPayload.vacationDaysPerYear;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function parseWeekdayAvailability(
|
||||
value: unknown,
|
||||
fallbackFte: number | null,
|
||||
): WeekdayAvailability {
|
||||
const fallback = createWeekdayAvailabilityFromFte(fallbackFte ?? 1);
|
||||
const source = asObject(value);
|
||||
|
||||
return {
|
||||
monday: isFiniteNumber(source.monday) ? source.monday : fallback.monday,
|
||||
tuesday: isFiniteNumber(source.tuesday) ? source.tuesday : fallback.tuesday,
|
||||
wednesday: isFiniteNumber(source.wednesday) ? source.wednesday : fallback.wednesday,
|
||||
thursday: isFiniteNumber(source.thursday) ? source.thursday : fallback.thursday,
|
||||
friday: isFiniteNumber(source.friday) ? source.friday : fallback.friday,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReferenceDataMaps {
|
||||
clientIdByCode: Map<string, string>;
|
||||
clientIdByName: Map<string, string>;
|
||||
countryIdByCode: Map<string, string>;
|
||||
managementLevelGroupByName: Map<string, { id: string; name: string; targetPercentage: number }>;
|
||||
managementLevelIdByName: Map<string, string>;
|
||||
metroCityIdByName: Map<string, string>;
|
||||
orgUnitIdByLevelAndName: Map<string, string>;
|
||||
roleIdByName: Map<string, string>;
|
||||
utilizationCategoryIdByCode: Map<string, string>;
|
||||
}
|
||||
|
||||
export function buildReferenceDataMaps(data: {
|
||||
clients: { id: string; code: string | null; name: string }[];
|
||||
countries: { id: string; code: string }[];
|
||||
managementLevelGroups: { id: string; name: string; targetPercentage: number }[];
|
||||
managementLevels: { id: string; name: string }[];
|
||||
metroCities: { id: string; name: string }[];
|
||||
orgUnits: { id: string; level: number; name: string }[];
|
||||
roles: { id: string; name: string }[];
|
||||
utilizationCategories: { id: string; code: string }[];
|
||||
}): ReferenceDataMaps {
|
||||
return {
|
||||
clientIdByCode: new Map(
|
||||
data.clients.filter((client) => client.code).map((client) => [client.code!, client.id]),
|
||||
),
|
||||
clientIdByName: new Map(
|
||||
data.clients.map((client) => [client.name.toLowerCase(), client.id]),
|
||||
),
|
||||
countryIdByCode: new Map(
|
||||
data.countries.map((country) => [country.code, country.id]),
|
||||
),
|
||||
managementLevelGroupByName: new Map(
|
||||
data.managementLevelGroups.map((group) => [group.name, group]),
|
||||
),
|
||||
managementLevelIdByName: new Map(
|
||||
data.managementLevels.map((level) => [level.name, level.id]),
|
||||
),
|
||||
metroCityIdByName: new Map(
|
||||
data.metroCities.map((metroCity) => [metroCity.name.toLowerCase(), metroCity.id]),
|
||||
),
|
||||
orgUnitIdByLevelAndName: new Map(
|
||||
data.orgUnits.map((orgUnit) => [`${orgUnit.level}:${orgUnit.name.toLowerCase()}`, orgUnit.id]),
|
||||
),
|
||||
roleIdByName: new Map(
|
||||
data.roles.map((role) => [role.name, role.id]),
|
||||
),
|
||||
utilizationCategoryIdByCode: new Map(
|
||||
data.utilizationCategories.map((category) => [category.code, category.id]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
export type CommitDbClient = Pick<
|
||||
PrismaClient,
|
||||
| "$transaction"
|
||||
| "assignment"
|
||||
| "client"
|
||||
| "country"
|
||||
| "importBatch"
|
||||
| "managementLevel"
|
||||
| "managementLevelGroup"
|
||||
| "metroCity"
|
||||
| "orgUnit"
|
||||
| "project"
|
||||
| "resource"
|
||||
| "resourceRole"
|
||||
| "role"
|
||||
| "stagedAssignment"
|
||||
| "stagedAvailabilityRule"
|
||||
| "stagedProject"
|
||||
| "stagedResource"
|
||||
| "stagedUnresolvedRecord"
|
||||
| "stagedVacation"
|
||||
| "utilizationCategory"
|
||||
| "user"
|
||||
| "vacation"
|
||||
| "vacationEntitlement"
|
||||
>;
|
||||
|
||||
export type TxClient = Parameters<Parameters<CommitDbClient["$transaction"]>[0]>[0];
|
||||
|
||||
export interface MergedStagedResource {
|
||||
availability: Prisma.InputJsonValue | null;
|
||||
canonicalExternalId: string;
|
||||
chapter: string | null;
|
||||
chargeabilityTarget: number | null;
|
||||
clientUnitName: string | null;
|
||||
countryCode: string | null;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
fte: number | null;
|
||||
lcrCents: number | null;
|
||||
managementLevelGroupName: string | null;
|
||||
managementLevelName: string | null;
|
||||
metroCityName: string | null;
|
||||
rawPayload: Record<string, unknown>;
|
||||
resourceType: NonNullable<Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>[number]["resourceType"]> | null;
|
||||
roleTokens: Set<string>;
|
||||
sourceKinds: string[];
|
||||
ucrCents: number | null;
|
||||
vacationDaysPerYear: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AggregatedAssignment {
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
projectId: string;
|
||||
projectShortCode: string;
|
||||
resourceId: string;
|
||||
resourceKey: string;
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
sourceDates: string[];
|
||||
startDate: Date;
|
||||
utilizationCategoryCode: string | null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
import {
|
||||
DISPO_INTERNAL_PROJECT_BUCKETS,
|
||||
normalizeDispoRoleToken,
|
||||
} from "@planarchy/shared";
|
||||
import type { TxClient, AggregatedAssignment } from "./commit-dispo-batch-types.js";
|
||||
import { deriveTbdDispoProjectIdentity } from "./tbd-projects.js";
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function normalizeDate(date: Date): Date {
|
||||
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function getDateKey(date: Date): string {
|
||||
return normalizeDate(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const next = normalizeDate(date);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function roundToOneDecimal(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
const WEEKDAY_KEYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
|
||||
const WORKDAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
|
||||
|
||||
function resolveInternalProjectShortCode(utilizationCategoryCode: string | null): string | null {
|
||||
return (
|
||||
DISPO_INTERNAL_PROJECT_BUCKETS.find(
|
||||
(bucket) => bucket.utilizationCategoryCode === utilizationCategoryCode,
|
||||
)?.shortCode ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function aggregateAssignments(
|
||||
rows: Awaited<ReturnType<TxClient["stagedAssignment"]["findMany"]>>,
|
||||
resourceIdByKey: ReadonlyMap<string, string>,
|
||||
projectIdByShortCode: ReadonlyMap<string, string>,
|
||||
roleIdByName: ReadonlyMap<string, string>,
|
||||
resourceRoleNameByKey: ReadonlyMap<string, string>,
|
||||
importTbdProjects: boolean,
|
||||
): AggregatedAssignment[] {
|
||||
const resolvedRows = rows
|
||||
.filter((row) => !row.isUnassigned && (!row.isTbd || importTbdProjects))
|
||||
.map((row) => {
|
||||
const projectShortCode = row.isInternal
|
||||
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
|
||||
: row.isTbd
|
||||
? deriveTbdDispoProjectIdentity(
|
||||
String(asObject(row.rawPayload).rawToken ?? ""),
|
||||
row.utilizationCategoryCode ?? null,
|
||||
).shortCode
|
||||
: (row.projectKey ?? null);
|
||||
const roleName =
|
||||
row.roleName ??
|
||||
normalizeDispoRoleToken(row.roleToken) ??
|
||||
resourceRoleNameByKey.get(row.resourceExternalId) ??
|
||||
null;
|
||||
const resourceId = resourceIdByKey.get(row.resourceExternalId);
|
||||
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
|
||||
const roleId = roleName ? roleIdByName.get(roleName) : null;
|
||||
|
||||
if (!resourceId) {
|
||||
throw new Error(`Unable to resolve resource "${row.resourceExternalId}" during assignment commit`);
|
||||
}
|
||||
if (!projectShortCode || !projectId) {
|
||||
throw new Error(
|
||||
`Unable to resolve project for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
|
||||
);
|
||||
}
|
||||
if (!roleName || !roleId) {
|
||||
throw new Error(
|
||||
`Unable to resolve role for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
|
||||
);
|
||||
}
|
||||
if (row.assignmentDate === null || row.hoursPerDay === null || row.percentage === null) {
|
||||
throw new Error(`Assignment row "${row.id}" is missing normalized date or load information`);
|
||||
}
|
||||
|
||||
return {
|
||||
assignmentDate: normalizeDate(row.assignmentDate),
|
||||
hoursPerDay: row.hoursPerDay,
|
||||
percentage: row.percentage,
|
||||
projectId,
|
||||
projectShortCode,
|
||||
resourceId,
|
||||
resourceKey: row.resourceExternalId,
|
||||
roleId,
|
||||
roleName,
|
||||
utilizationCategoryCode: row.utilizationCategoryCode ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) =>
|
||||
left.resourceKey.localeCompare(right.resourceKey) ||
|
||||
left.projectShortCode.localeCompare(right.projectShortCode) ||
|
||||
left.roleName.localeCompare(right.roleName) ||
|
||||
left.assignmentDate.getTime() - right.assignmentDate.getTime(),
|
||||
);
|
||||
|
||||
const aggregated: AggregatedAssignment[] = [];
|
||||
|
||||
for (const row of resolvedRows) {
|
||||
const previous = aggregated.at(-1);
|
||||
const canMerge = previous &&
|
||||
previous.resourceId === row.resourceId &&
|
||||
previous.projectId === row.projectId &&
|
||||
previous.roleId === row.roleId &&
|
||||
previous.hoursPerDay === row.hoursPerDay &&
|
||||
previous.percentage === row.percentage &&
|
||||
previous.endDate.getTime() === addDays(row.assignmentDate, -1).getTime();
|
||||
|
||||
if (canMerge) {
|
||||
previous.endDate = row.assignmentDate;
|
||||
previous.sourceDates.push(getDateKey(row.assignmentDate));
|
||||
continue;
|
||||
}
|
||||
|
||||
aggregated.push({
|
||||
endDate: row.assignmentDate,
|
||||
hoursPerDay: row.hoursPerDay,
|
||||
percentage: row.percentage,
|
||||
projectId: row.projectId,
|
||||
projectShortCode: row.projectShortCode,
|
||||
resourceId: row.resourceId,
|
||||
resourceKey: row.resourceKey,
|
||||
roleId: row.roleId,
|
||||
roleName: row.roleName,
|
||||
sourceDates: [getDateKey(row.assignmentDate)],
|
||||
startDate: row.assignmentDate,
|
||||
utilizationCategoryCode: row.utilizationCategoryCode,
|
||||
});
|
||||
}
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
export function deriveOverlayAvailability(
|
||||
baseAvailability: WeekdayAvailability,
|
||||
rules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
|
||||
): WeekdayAvailability {
|
||||
const next = { ...baseAvailability };
|
||||
const weekdayVotes = new Map<string, Map<number, number>>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const date = rule.effectiveStartDate ?? rule.effectiveEndDate;
|
||||
if (!date) {
|
||||
continue;
|
||||
}
|
||||
const weekdayIndex = normalizeDate(date).getUTCDay();
|
||||
if (weekdayIndex === 0 || weekdayIndex === 6) {
|
||||
continue;
|
||||
}
|
||||
const weekdayKey = WEEKDAY_KEYS[weekdayIndex] as (typeof WORKDAY_KEYS)[number] | undefined;
|
||||
if (!weekdayKey || !WORKDAY_KEYS.includes(weekdayKey)) {
|
||||
continue;
|
||||
}
|
||||
const availableHours = rule.availableHours ?? (
|
||||
rule.percentage !== null && rule.percentage !== undefined
|
||||
? roundToOneDecimal((rule.percentage / 100) * 8)
|
||||
: null
|
||||
);
|
||||
if (availableHours === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hoursMap = weekdayVotes.get(weekdayKey) ?? new Map<number, number>();
|
||||
hoursMap.set(availableHours, (hoursMap.get(availableHours) ?? 0) + 1);
|
||||
weekdayVotes.set(weekdayKey, hoursMap);
|
||||
}
|
||||
|
||||
for (const weekdayKey of WORKDAY_KEYS) {
|
||||
const hoursMap = weekdayVotes.get(weekdayKey);
|
||||
if (!hoursMap || hoursMap.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstEntry = [...hoursMap.entries()].sort(
|
||||
(left, right) => right[1] - left[1] || left[0] - right[0],
|
||||
)[0];
|
||||
if (!firstEntry) {
|
||||
continue;
|
||||
}
|
||||
const [resolvedHours] = firstEntry;
|
||||
next[weekdayKey] = Math.min(next[weekdayKey], resolvedHours);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CommitDbClient } from "./commit-dispo-batch-types.js";
|
||||
import { StagedRecordStatus } from "@planarchy/db";
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function isAllowedUnresolvedRecord(
|
||||
record: Awaited<ReturnType<CommitDbClient["stagedUnresolvedRecord"]["findMany"]>>[number],
|
||||
): boolean {
|
||||
const message = record.message.toLowerCase();
|
||||
const hint = (record.resolutionHint ?? "").toLowerCase();
|
||||
const rawToken = String(asObject(record.normalizedData).rawToken ?? "").toLowerCase();
|
||||
|
||||
return record.recordType === "PROJECT" && (
|
||||
message.includes("[tbd]") ||
|
||||
hint.includes("[tbd]") ||
|
||||
rawToken.includes("[tbd]")
|
||||
);
|
||||
}
|
||||
|
||||
export interface BatchValidationResult {
|
||||
batchId: string;
|
||||
batchSummary: unknown;
|
||||
blockingUnresolved: number;
|
||||
skippedTbdUnresolved: number;
|
||||
}
|
||||
|
||||
export async function validateDispoBatch(
|
||||
db: CommitDbClient,
|
||||
input: {
|
||||
allowTbdUnresolved?: boolean;
|
||||
importBatchId: string;
|
||||
importTbdProjects?: boolean;
|
||||
},
|
||||
): Promise<BatchValidationResult> {
|
||||
const batch = await db.importBatch.findUnique({
|
||||
where: { id: input.importBatchId },
|
||||
select: { id: true, status: true, summary: true },
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error(`Import batch "${input.importBatchId}" not found`);
|
||||
}
|
||||
|
||||
if (!["STAGED", "REVIEW_READY", "APPROVED"].includes(batch.status)) {
|
||||
throw new Error(`Import batch "${batch.id}" is not ready to commit from status "${batch.status}"`);
|
||||
}
|
||||
|
||||
const unresolved = await db.stagedUnresolvedRecord.findMany({
|
||||
where: {
|
||||
importBatchId: batch.id,
|
||||
status: StagedRecordStatus.UNRESOLVED,
|
||||
},
|
||||
});
|
||||
const blockingUnresolved = unresolved.filter(
|
||||
(record) =>
|
||||
!(
|
||||
(input.allowTbdUnresolved ?? true) ||
|
||||
(input.importTbdProjects && isAllowedUnresolvedRecord(record))
|
||||
) || !isAllowedUnresolvedRecord(record),
|
||||
);
|
||||
const skippedTbdUnresolved = unresolved.length - blockingUnresolved.length;
|
||||
|
||||
if (blockingUnresolved.length > 0) {
|
||||
throw new Error(
|
||||
`Import batch "${batch.id}" still has ${blockingUnresolved.length} blocking unresolved staged record(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
batchId: batch.id,
|
||||
batchSummary: batch.summary,
|
||||
blockingUnresolved: blockingUnresolved.length,
|
||||
skippedTbdUnresolved,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user