chore(repo): initialize planarchy workspace
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export interface DemandRequirementFillProgressInput {
|
||||
demandRequirementId: string;
|
||||
headcount: number;
|
||||
}
|
||||
|
||||
export const UPDATED_DEMAND_REQUIREMENT_SELECT = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
} as const;
|
||||
|
||||
export async function applyDemandRequirementFillProgress(
|
||||
db: DbClient,
|
||||
input: DemandRequirementFillProgressInput,
|
||||
) {
|
||||
const updatedDemandRequirement = await db.demandRequirement.update({
|
||||
where: { id: input.demandRequirementId },
|
||||
data:
|
||||
input.headcount > 1
|
||||
? { headcount: input.headcount - 1 }
|
||||
: { status: AllocationStatus.COMPLETED },
|
||||
select: UPDATED_DEMAND_REQUIREMENT_SELECT,
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedDemandRequirement,
|
||||
status: updatedDemandRequirement.status as AllocationStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
AllocationLike,
|
||||
AllocationReadModel,
|
||||
Assignment,
|
||||
DemandRequirement,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
function toDemandRequirement<TAllocation extends AllocationLike>(
|
||||
allocation: TAllocation,
|
||||
): DemandRequirement<TAllocation> {
|
||||
return {
|
||||
...allocation,
|
||||
kind: "demand",
|
||||
sourceAllocationId: allocation.entityId ?? allocation.id,
|
||||
resourceId: null,
|
||||
isPlaceholder: true,
|
||||
requestedHeadcount: allocation.headcount,
|
||||
unfilledHeadcount: allocation.headcount,
|
||||
};
|
||||
}
|
||||
|
||||
function toAssignment<TAllocation extends AllocationLike>(
|
||||
allocation: TAllocation,
|
||||
): Assignment<TAllocation> {
|
||||
return {
|
||||
...allocation,
|
||||
kind: "assignment",
|
||||
sourceAllocationId: allocation.entityId ?? allocation.id,
|
||||
resourceId: allocation.resourceId as string,
|
||||
isPlaceholder: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAllocationReadModel<TAllocation extends AllocationLike>(
|
||||
allocations: TAllocation[],
|
||||
): AllocationReadModel<TAllocation> {
|
||||
const demands: DemandRequirement<TAllocation>[] = [];
|
||||
const assignments: Assignment<TAllocation>[] = [];
|
||||
|
||||
for (const allocation of allocations) {
|
||||
if (allocation.isPlaceholder || allocation.resourceId === null) {
|
||||
demands.push(toDemandRequirement(allocation));
|
||||
continue;
|
||||
}
|
||||
|
||||
assignments.push(toAssignment(allocation));
|
||||
}
|
||||
|
||||
return {
|
||||
allocations,
|
||||
demands,
|
||||
assignments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
AllocationLike,
|
||||
AllocationReadModel,
|
||||
Assignment,
|
||||
DemandRequirement,
|
||||
} from "@planarchy/shared";
|
||||
type SplitAllocationEntry = AllocationLike;
|
||||
type SplitProjectSummary = NonNullable<SplitAllocationEntry["project"]>;
|
||||
type SplitResourceSummary = NonNullable<SplitAllocationEntry["resource"]>;
|
||||
type SplitRoleSummary = NonNullable<SplitAllocationEntry["roleEntity"]>;
|
||||
type SplitDemandAllocationEntry = SplitAllocationEntry & {
|
||||
resourceId: null;
|
||||
isPlaceholder: true;
|
||||
};
|
||||
type SplitAssignmentAllocationEntry = SplitAllocationEntry & {
|
||||
resourceId: string;
|
||||
isPlaceholder: false;
|
||||
};
|
||||
|
||||
export interface SplitDemandRequirementRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
headcount: number;
|
||||
status: string;
|
||||
metadata: unknown;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
project?: SplitProjectSummary;
|
||||
roleEntity?: SplitRoleSummary | null;
|
||||
}
|
||||
|
||||
export interface SplitAssignmentRecord {
|
||||
id: string;
|
||||
demandRequirementId?: string | null;
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
dailyCostCents: number;
|
||||
status: string;
|
||||
metadata: unknown;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
resource?: SplitResourceSummary | null;
|
||||
project?: SplitProjectSummary;
|
||||
roleEntity?: SplitRoleSummary | null;
|
||||
}
|
||||
|
||||
export interface BuildSplitAllocationReadModelInput {
|
||||
demandRequirements: SplitDemandRequirementRecord[];
|
||||
assignments: SplitAssignmentRecord[];
|
||||
}
|
||||
|
||||
function compareEntries(
|
||||
left: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
|
||||
right: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
|
||||
): number {
|
||||
const startDelta =
|
||||
new Date(left.startDate).getTime() - new Date(right.startDate).getTime();
|
||||
if (startDelta !== 0) {
|
||||
return startDelta;
|
||||
}
|
||||
|
||||
const resourceDelta = (left.resourceId ?? "").localeCompare(right.resourceId ?? "");
|
||||
if (resourceDelta !== 0) {
|
||||
return resourceDelta;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function toDemandAllocationEntry(
|
||||
demandRequirement: SplitDemandRequirementRecord,
|
||||
): SplitDemandAllocationEntry {
|
||||
return {
|
||||
id: demandRequirement.id,
|
||||
entityId: demandRequirement.id,
|
||||
resourceId: null,
|
||||
projectId: demandRequirement.projectId,
|
||||
startDate: demandRequirement.startDate,
|
||||
endDate: demandRequirement.endDate,
|
||||
hoursPerDay: demandRequirement.hoursPerDay,
|
||||
percentage: demandRequirement.percentage,
|
||||
role: demandRequirement.role,
|
||||
roleId: demandRequirement.roleId,
|
||||
isPlaceholder: true,
|
||||
headcount: demandRequirement.headcount,
|
||||
dailyCostCents: 0,
|
||||
status: demandRequirement.status,
|
||||
metadata: demandRequirement.metadata,
|
||||
createdAt: demandRequirement.createdAt,
|
||||
updatedAt: demandRequirement.updatedAt,
|
||||
...(demandRequirement.project ? { project: demandRequirement.project } : {}),
|
||||
...(demandRequirement.roleEntity !== undefined
|
||||
? { roleEntity: demandRequirement.roleEntity ?? null }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toAssignmentAllocationEntry(
|
||||
assignment: SplitAssignmentRecord,
|
||||
): SplitAssignmentAllocationEntry {
|
||||
return {
|
||||
id: assignment.id,
|
||||
entityId: assignment.id,
|
||||
resourceId: assignment.resourceId,
|
||||
projectId: assignment.projectId,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
percentage: assignment.percentage,
|
||||
role: assignment.role,
|
||||
roleId: assignment.roleId,
|
||||
isPlaceholder: false,
|
||||
headcount: 1,
|
||||
dailyCostCents: assignment.dailyCostCents,
|
||||
status: assignment.status,
|
||||
metadata: assignment.metadata,
|
||||
createdAt: assignment.createdAt,
|
||||
updatedAt: assignment.updatedAt,
|
||||
...(assignment.resource !== undefined ? { resource: assignment.resource ?? null } : {}),
|
||||
...(assignment.project ? { project: assignment.project } : {}),
|
||||
...(assignment.roleEntity !== undefined ? { roleEntity: assignment.roleEntity ?? null } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toDemandReadModelEntry(
|
||||
demandRequirement: SplitDemandRequirementRecord,
|
||||
): DemandRequirement<SplitAllocationEntry> {
|
||||
const entry = toDemandAllocationEntry(demandRequirement);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
kind: "demand",
|
||||
sourceAllocationId: demandRequirement.id,
|
||||
requestedHeadcount: demandRequirement.headcount,
|
||||
unfilledHeadcount: demandRequirement.headcount,
|
||||
};
|
||||
}
|
||||
|
||||
function toAssignmentReadModelEntry(
|
||||
assignment: SplitAssignmentRecord,
|
||||
): Assignment<SplitAllocationEntry> {
|
||||
const entry = toAssignmentAllocationEntry(assignment);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
kind: "assignment",
|
||||
sourceAllocationId: assignment.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
}: BuildSplitAllocationReadModelInput): AllocationReadModel<SplitAllocationEntry> {
|
||||
return {
|
||||
allocations: [
|
||||
...demandRequirements.map(toDemandAllocationEntry),
|
||||
...assignments.map(toAssignmentAllocationEntry),
|
||||
].sort(compareEntries),
|
||||
demands: demandRequirements.map(toDemandReadModelEntry).sort(compareEntries),
|
||||
assignments: assignments.map(toAssignmentReadModelEntry).sort(compareEntries),
|
||||
};
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface CountEstimateHandoffPlanningEntriesInput {
|
||||
projectId: string;
|
||||
estimateVersionId: string;
|
||||
}
|
||||
|
||||
export async function countEstimateHandoffPlanningEntries(
|
||||
db: DbClient,
|
||||
input: CountEstimateHandoffPlanningEntriesInput,
|
||||
): Promise<number> {
|
||||
const handoffWhere = {
|
||||
projectId: input.projectId,
|
||||
metadata: {
|
||||
path: ["estimateHandoff", "estimateVersionId"],
|
||||
equals: input.estimateVersionId,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
db.demandRequirement.findMany({
|
||||
where: handoffWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: handoffWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
demandRequirementId: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
}).allocations.length;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface CountPlanningEntriesInput {
|
||||
projectIds?: string[];
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export interface CountPlanningEntriesResult {
|
||||
countsByProjectId: Map<string, number>;
|
||||
countsByRoleId: Map<string, number>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
function normalizeIds(ids?: string[]): string[] | undefined {
|
||||
if (!ids) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = [...new Set(ids.filter(Boolean))];
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildScopedWhere(input: CountPlanningEntriesInput) {
|
||||
const projectIds = normalizeIds(input.projectIds);
|
||||
const roleIds = normalizeIds(input.roleIds);
|
||||
|
||||
if (input.projectIds && !projectIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input.roleIds && !roleIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
...(roleIds ? { roleId: { in: roleIds } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function countPlanningEntries(
|
||||
db: DbClient,
|
||||
input: CountPlanningEntriesInput = {},
|
||||
): Promise<CountPlanningEntriesResult> {
|
||||
const scopedWhere = buildScopedWhere(input);
|
||||
if (scopedWhere === null) {
|
||||
return {
|
||||
countsByProjectId: new Map(),
|
||||
countsByRoleId: new Map(),
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
db.demandRequirement.findMany({
|
||||
where: scopedWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
db.assignment.findMany({
|
||||
where: scopedWhere,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
demandRequirementId: true,
|
||||
resourceId: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const readModel = buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
});
|
||||
|
||||
const countsByProjectId = new Map<string, number>();
|
||||
const countsByRoleId = new Map<string, number>();
|
||||
|
||||
for (const allocation of readModel.allocations) {
|
||||
countsByProjectId.set(
|
||||
allocation.projectId,
|
||||
(countsByProjectId.get(allocation.projectId) ?? 0) + 1,
|
||||
);
|
||||
|
||||
if (allocation.roleId) {
|
||||
countsByRoleId.set(
|
||||
allocation.roleId,
|
||||
(countsByRoleId.get(allocation.roleId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countsByProjectId,
|
||||
countsByRoleId,
|
||||
totalCount: readModel.allocations.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { calculateAllocation, validateAvailability } from "@planarchy/engine";
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import {
|
||||
type Allocation,
|
||||
type CreateAssignmentInput,
|
||||
type WeekdayAvailability,
|
||||
} from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { listAssignmentBookings } from "./list-assignment-bookings.js";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export const ASSIGNMENT_RELATIONS_INCLUDE = {
|
||||
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
|
||||
project: { select: { id: true, name: true, shortCode: true } },
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
demandRequirement: {
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
percentage: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AssignmentWithRelations = Prisma.AssignmentGetPayload<{
|
||||
include: typeof ASSIGNMENT_RELATIONS_INCLUDE;
|
||||
}>;
|
||||
|
||||
async function getVacationDates(db: DbClient, resourceId: string, startDate: Date, endDate: Date) {
|
||||
const vacationDates: Date[] = [];
|
||||
|
||||
try {
|
||||
const vacations = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
});
|
||||
|
||||
for (const vacation of vacations) {
|
||||
const current = new Date(vacation.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const vacationEnd = new Date(vacation.endDate);
|
||||
vacationEnd.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current <= vacationEnd) {
|
||||
vacationDates.push(new Date(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Vacation persistence may not be available in all environments yet.
|
||||
}
|
||||
|
||||
return vacationDates;
|
||||
}
|
||||
|
||||
export async function createAssignment(
|
||||
db: DbClient,
|
||||
input: CreateAssignmentInput,
|
||||
): Promise<AssignmentWithRelations> {
|
||||
const project = await db.project.findUnique({ where: { id: input.projectId } });
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const resource = await db.resource.findUnique({ where: { id: input.resourceId } });
|
||||
if (!resource) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
}
|
||||
|
||||
if (input.demandRequirementId) {
|
||||
const demandRequirement = await db.demandRequirement.findUnique({
|
||||
where: { id: input.demandRequirementId },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
|
||||
if (!demandRequirement) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
||||
}
|
||||
|
||||
if (demandRequirement.projectId !== input.projectId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Demand requirement belongs to a different project",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingBookings = await listAssignmentBookings(
|
||||
db as unknown as Parameters<typeof listAssignmentBookings>[0],
|
||||
{
|
||||
resourceIds: [input.resourceId],
|
||||
},
|
||||
);
|
||||
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const availabilityWindows = existingBookings.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
})) as Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[];
|
||||
|
||||
const availabilityResult = validateAvailability(
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
input.hoursPerDay,
|
||||
availability,
|
||||
availabilityWindows,
|
||||
);
|
||||
|
||||
if (!availabilityResult.valid && availabilityResult.totalConflictDays > 5) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Resource has availability conflicts on ${availabilityResult.totalConflictDays} days`,
|
||||
});
|
||||
}
|
||||
|
||||
const vacationDates = await getVacationDates(
|
||||
db,
|
||||
input.resourceId,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const calculation = calculateAllocation({
|
||||
lcrCents: resource.lcrCents,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
availability,
|
||||
vacationDates,
|
||||
});
|
||||
|
||||
const assignment = await db.assignment.create({
|
||||
data: {
|
||||
demandRequirementId: input.demandRequirementId ?? null,
|
||||
resourceId: input.resourceId,
|
||||
projectId: input.projectId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
percentage: input.percentage,
|
||||
role: input.role ?? null,
|
||||
roleId: input.roleId ?? null,
|
||||
dailyCostCents: input.dailyCostCents ?? calculation.dailyCostCents,
|
||||
status: input.status,
|
||||
metadata: input.metadata as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Assignment",
|
||||
entityId: assignment.id,
|
||||
action: "CREATE",
|
||||
changes: { after: assignment } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return assignment;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { type CreateDemandRequirementInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export const DEMAND_REQUIREMENT_RELATIONS_INCLUDE = {
|
||||
project: { select: { id: true, name: true, shortCode: true } },
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
} as const;
|
||||
|
||||
export type DemandRequirementWithRelations = Prisma.DemandRequirementGetPayload<{
|
||||
include: typeof DEMAND_REQUIREMENT_RELATIONS_INCLUDE;
|
||||
}>;
|
||||
|
||||
export async function createDemandRequirement(
|
||||
db: DbClient,
|
||||
input: CreateDemandRequirementInput,
|
||||
): Promise<DemandRequirementWithRelations> {
|
||||
const project = await db.project.findUnique({ where: { id: input.projectId } });
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const demandRequirement = await db.demandRequirement.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
percentage: input.percentage,
|
||||
role: input.role ?? null,
|
||||
roleId: input.roleId ?? null,
|
||||
headcount: input.headcount ?? 1,
|
||||
status: input.status,
|
||||
metadata: input.metadata as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "DemandRequirement",
|
||||
entityId: demandRequirement.id,
|
||||
action: "CREATE",
|
||||
changes: { after: demandRequirement } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return demandRequirement;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { AllocationEntryResolution } from "./load-allocation-entry.js";
|
||||
import { deleteAssignment } from "./delete-assignment.js";
|
||||
import { deleteDemandRequirement } from "./delete-demand-requirement.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface DeleteAllocationEntryResult {
|
||||
deletedId: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
strategy: "explicit_demand" | "explicit_assignment";
|
||||
}
|
||||
|
||||
export async function deleteAllocationEntry(
|
||||
db: DbClient,
|
||||
resolved: AllocationEntryResolution,
|
||||
): Promise<DeleteAllocationEntryResult> {
|
||||
if (resolved.kind === "demand") {
|
||||
const result = await deleteDemandRequirement(
|
||||
db as Parameters<typeof deleteDemandRequirement>[0],
|
||||
resolved.demandRequirement.id,
|
||||
);
|
||||
|
||||
return {
|
||||
deletedId: result.deletedId,
|
||||
projectId: result.projectId,
|
||||
resourceId: null,
|
||||
strategy: "explicit_demand",
|
||||
};
|
||||
}
|
||||
|
||||
if (resolved.kind === "assignment") {
|
||||
const result = await deleteAssignment(
|
||||
db as Parameters<typeof deleteAssignment>[0],
|
||||
resolved.assignment.id,
|
||||
);
|
||||
|
||||
return {
|
||||
deletedId: result.deletedId,
|
||||
projectId: result.projectId,
|
||||
resourceId: result.resourceId,
|
||||
strategy: "explicit_assignment",
|
||||
};
|
||||
}
|
||||
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "assignment">
|
||||
| Pick<Prisma.TransactionClient, "assignment">;
|
||||
|
||||
export interface DeleteAssignmentResult {
|
||||
deletedId: string;
|
||||
projectId: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export async function deleteAssignment(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<DeleteAssignmentResult> {
|
||||
const assignment = await db.assignment.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
resourceId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new Error("Assignment not found");
|
||||
}
|
||||
|
||||
await db.assignment.delete({
|
||||
where: { id: assignment.id },
|
||||
});
|
||||
|
||||
return {
|
||||
deletedId: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface DeleteDemandRequirementResult {
|
||||
deletedId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export async function deleteDemandRequirement(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<DeleteDemandRequirementResult> {
|
||||
const demandRequirement = await db.demandRequirement.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!demandRequirement) {
|
||||
throw new Error("Demand requirement not found");
|
||||
}
|
||||
|
||||
await db.assignment.updateMany({
|
||||
where: { demandRequirementId: demandRequirement.id },
|
||||
data: { demandRequirementId: null },
|
||||
});
|
||||
await db.demandRequirement.delete({
|
||||
where: { id: demandRequirement.id },
|
||||
});
|
||||
|
||||
return {
|
||||
deletedId: demandRequirement.id,
|
||||
projectId: demandRequirement.projectId,
|
||||
};
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import {
|
||||
AllocationStatus,
|
||||
type FillDemandRequirementInput,
|
||||
} from "@planarchy/shared";
|
||||
import {
|
||||
createAssignment,
|
||||
type AssignmentWithRelations,
|
||||
} from "./create-assignment.js";
|
||||
import { applyDemandRequirementFillProgress } from "./apply-demand-requirement-fill-progress.js";
|
||||
|
||||
export interface DemandRequirementFillTarget {
|
||||
id: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
headcount: number;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
export interface FillDemandRequirementWithLegacySyncResult {
|
||||
assignment: AssignmentWithRelations;
|
||||
updatedDemandRequirement: Awaited<
|
||||
ReturnType<typeof applyDemandRequirementFillProgress>
|
||||
>;
|
||||
}
|
||||
|
||||
export async function fillDemandRequirementWithLegacySync(
|
||||
db: PrismaClient,
|
||||
demandRequirement: DemandRequirementFillTarget,
|
||||
input: FillDemandRequirementInput,
|
||||
): Promise<FillDemandRequirementWithLegacySyncResult> {
|
||||
const hoursPerDay = input.hoursPerDay ?? demandRequirement.hoursPerDay;
|
||||
const percentage = Math.max(1, Math.min(100, Math.round((hoursPerDay / 8) * 100)));
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
const createdAssignment = await createAssignment(tx, {
|
||||
demandRequirementId: demandRequirement.id,
|
||||
resourceId: input.resourceId,
|
||||
projectId: demandRequirement.projectId,
|
||||
startDate: demandRequirement.startDate,
|
||||
endDate: demandRequirement.endDate,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
role: demandRequirement.role ?? undefined,
|
||||
roleId: demandRequirement.roleId ?? undefined,
|
||||
status: input.status ?? AllocationStatus.PROPOSED,
|
||||
metadata: (demandRequirement.metadata as Record<string, unknown> | null) ?? {},
|
||||
});
|
||||
|
||||
const updatedDemandRequirement = await applyDemandRequirementFillProgress(tx, {
|
||||
demandRequirementId: demandRequirement.id,
|
||||
headcount: demandRequirement.headcount,
|
||||
});
|
||||
|
||||
return {
|
||||
assignment: createdAssignment,
|
||||
updatedDemandRequirement,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { type AssignmentWithRelations } from "./create-assignment.js";
|
||||
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
|
||||
|
||||
export interface FillDemandRequirementResult {
|
||||
assignment: AssignmentWithRelations;
|
||||
updatedDemandRequirement: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
headcount: number;
|
||||
status: AllocationStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FillDemandRequirementOptions {
|
||||
}
|
||||
export async function fillDemandRequirement(
|
||||
db: PrismaClient,
|
||||
input: FillDemandRequirementInput,
|
||||
): Promise<FillDemandRequirementResult> {
|
||||
const demandRequirement = await db.demandRequirement.findUnique({
|
||||
where: { id: input.demandRequirementId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!demandRequirement) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
||||
}
|
||||
|
||||
if (demandRequirement.status === AllocationStatus.CANCELLED) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Demand requirement is cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
if (demandRequirement.status === AllocationStatus.COMPLETED) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Demand requirement is already completed",
|
||||
});
|
||||
}
|
||||
|
||||
return fillDemandRequirementWithLegacySync(db, demandRequirement, input);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import type { FillOpenDemandByAllocationInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { fillDemandRequirement } from "./fill-demand-requirement.js";
|
||||
import { loadAllocationEntry } from "./load-allocation-entry.js";
|
||||
|
||||
export interface FillOpenDemandResult {
|
||||
strategy: "demand_requirement" | "placeholder";
|
||||
createdAllocation: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
};
|
||||
updatedAllocation: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function toDemandRequirementFillResult(
|
||||
result: Awaited<ReturnType<typeof fillDemandRequirement>>,
|
||||
): FillOpenDemandResult {
|
||||
return {
|
||||
strategy: "demand_requirement",
|
||||
createdAllocation: {
|
||||
id: result.assignment.id,
|
||||
projectId: result.assignment.projectId,
|
||||
resourceId: result.assignment.resourceId,
|
||||
},
|
||||
updatedAllocation: {
|
||||
id: result.updatedDemandRequirement.id,
|
||||
projectId: result.updatedDemandRequirement.projectId,
|
||||
resourceId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fillOpenDemand(
|
||||
db: PrismaClient,
|
||||
input: FillOpenDemandByAllocationInput,
|
||||
): Promise<FillOpenDemandResult> {
|
||||
const allocation = await loadAllocationEntry(db, input.allocationId);
|
||||
|
||||
if (allocation.kind === "assignment") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Allocation is already filled",
|
||||
});
|
||||
}
|
||||
|
||||
if (allocation.kind === "demand") {
|
||||
const result = await fillDemandRequirement(db, {
|
||||
demandRequirementId: allocation.demandRequirement.id,
|
||||
resourceId: input.resourceId,
|
||||
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
});
|
||||
|
||||
return toDemandRequirementFillResult(result);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Unsupported allocation entry resolution",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
|
||||
type AssignmentBookingsDbClient = Pick<PrismaClient, "assignment">;
|
||||
|
||||
export interface ListAssignmentBookingsInput {
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
resourceIds?: string[] | undefined;
|
||||
projectIds?: string[] | undefined;
|
||||
excludeAssignmentIds?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface AssignmentBookingWithFallback {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
dailyCostCents: number;
|
||||
status: string;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
status: string;
|
||||
orderType: string;
|
||||
};
|
||||
resource: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function listAssignmentBookings(
|
||||
db: AssignmentBookingsDbClient,
|
||||
input: ListAssignmentBookingsInput,
|
||||
): Promise<AssignmentBookingWithFallback[]> {
|
||||
const hasDateBounds = input.startDate !== undefined && input.endDate !== undefined;
|
||||
if (!hasDateBounds && (input.startDate !== undefined || input.endDate !== undefined)) {
|
||||
throw new Error("startDate and endDate must be provided together");
|
||||
}
|
||||
|
||||
const excludeAssignmentIds = input.excludeAssignmentIds?.filter(Boolean) ?? [];
|
||||
|
||||
const assignmentWhere = {
|
||||
status: { not: "CANCELLED" as const },
|
||||
...(hasDateBounds
|
||||
? {
|
||||
startDate: { lte: input.endDate! },
|
||||
endDate: { gte: input.startDate! },
|
||||
}
|
||||
: {}),
|
||||
...(input.resourceIds?.length ? { resourceId: { in: input.resourceIds } } : {}),
|
||||
...(input.projectIds?.length ? { projectId: { in: input.projectIds } } : {}),
|
||||
...(excludeAssignmentIds.length ? { id: { notIn: excludeAssignmentIds } } : {}),
|
||||
} satisfies Prisma.AssignmentWhereInput;
|
||||
|
||||
const assignmentSelect = {
|
||||
id: true,
|
||||
projectId: true,
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
dailyCostCents: true,
|
||||
status: true,
|
||||
project: {
|
||||
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
|
||||
},
|
||||
resource: {
|
||||
select: { id: true, displayName: true, chapter: true },
|
||||
},
|
||||
} satisfies Prisma.AssignmentSelect;
|
||||
|
||||
const assignments = await db.assignment.findMany({
|
||||
where: assignmentWhere,
|
||||
select: assignmentSelect,
|
||||
});
|
||||
|
||||
return assignments.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
dailyCostCents: assignment.dailyCostCents,
|
||||
status: assignment.status,
|
||||
project: assignment.project,
|
||||
resource: assignment.resource,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||
import type { AllocationWithDetails } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
|
||||
import {
|
||||
ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
type AssignmentWithRelations,
|
||||
} from "./create-assignment.js";
|
||||
import {
|
||||
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
type DemandRequirementWithRelations,
|
||||
} from "./create-demand-requirement.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export type AllocationEntryResolution =
|
||||
| {
|
||||
kind: "demand";
|
||||
entry: AllocationWithDetails;
|
||||
demandRequirement: DemandRequirementWithRelations;
|
||||
projectId: string;
|
||||
resourceId: null;
|
||||
}
|
||||
| {
|
||||
kind: "assignment";
|
||||
entry: AllocationWithDetails;
|
||||
assignment: AssignmentWithRelations;
|
||||
projectId: string;
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
function toDemandAllocationEntry(
|
||||
demandRequirement: DemandRequirementWithRelations,
|
||||
): AllocationWithDetails {
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements: [demandRequirement],
|
||||
assignments: [],
|
||||
}).allocations[0] as AllocationWithDetails;
|
||||
}
|
||||
|
||||
function toAssignmentAllocationEntry(
|
||||
assignment: AssignmentWithRelations,
|
||||
): AllocationWithDetails {
|
||||
return buildSplitAllocationReadModel({
|
||||
demandRequirements: [],
|
||||
assignments: [assignment],
|
||||
}).allocations[0] as AllocationWithDetails;
|
||||
}
|
||||
|
||||
async function loadDemandRequirementById(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<DemandRequirementWithRelations | null> {
|
||||
return db.demandRequirement.findUnique({
|
||||
where: { id },
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAssignmentById(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<AssignmentWithRelations | null> {
|
||||
return db.assignment.findUnique({
|
||||
where: { id },
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an id to a demand requirement or assignment.
|
||||
*/
|
||||
export async function findAllocationEntry(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<AllocationEntryResolution | null> {
|
||||
const [demandRequirement, assignment] = await Promise.all([
|
||||
loadDemandRequirementById(db, id),
|
||||
loadAssignmentById(db, id),
|
||||
]);
|
||||
|
||||
if (demandRequirement) {
|
||||
return {
|
||||
kind: "demand",
|
||||
entry: toDemandAllocationEntry(demandRequirement),
|
||||
demandRequirement,
|
||||
projectId: demandRequirement.projectId,
|
||||
resourceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (assignment) {
|
||||
return {
|
||||
kind: "assignment",
|
||||
entry: toAssignmentAllocationEntry(assignment),
|
||||
assignment,
|
||||
projectId: assignment.projectId,
|
||||
resourceId: assignment.resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadAllocationEntry(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
): Promise<AllocationEntryResolution> {
|
||||
const resolved = await findAllocationEntry(db, id);
|
||||
if (!resolved) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import type {
|
||||
AllocationWithDetails,
|
||||
UpdateAssignmentInput,
|
||||
UpdateDemandRequirementInput,
|
||||
} from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { findAllocationEntry } from "./load-allocation-entry.js";
|
||||
import { updateAssignment } from "./update-assignment.js";
|
||||
import { updateDemandRequirement } from "./update-demand-requirement.js";
|
||||
|
||||
type DbClient =
|
||||
| Pick<PrismaClient, "demandRequirement" | "assignment">
|
||||
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
|
||||
|
||||
export interface UpdateAllocationEntryInput {
|
||||
id: string;
|
||||
demandRequirementUpdate: UpdateDemandRequirementInput;
|
||||
assignmentUpdate: UpdateAssignmentInput;
|
||||
}
|
||||
|
||||
export interface UpdateAllocationEntryResult {
|
||||
allocation: AllocationWithDetails;
|
||||
strategy: "explicit_demand" | "explicit_assignment";
|
||||
}
|
||||
|
||||
export async function updateAllocationEntry(
|
||||
db: DbClient,
|
||||
input: UpdateAllocationEntryInput,
|
||||
): Promise<UpdateAllocationEntryResult> {
|
||||
const resolved = await findAllocationEntry(db, input.id);
|
||||
|
||||
if (!resolved) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
|
||||
}
|
||||
|
||||
if (resolved.kind === "demand") {
|
||||
const updatedDemandRequirement = await updateDemandRequirement(
|
||||
db as Parameters<typeof updateDemandRequirement>[0],
|
||||
resolved.demandRequirement.id,
|
||||
input.demandRequirementUpdate,
|
||||
);
|
||||
|
||||
return {
|
||||
allocation: {
|
||||
...resolved.entry,
|
||||
...updatedDemandRequirement,
|
||||
id: resolved.entry.id,
|
||||
resourceId: null,
|
||||
isPlaceholder: true,
|
||||
headcount: updatedDemandRequirement.headcount,
|
||||
dailyCostCents: 0,
|
||||
project: updatedDemandRequirement.project,
|
||||
roleEntity: updatedDemandRequirement.roleEntity ?? null,
|
||||
updatedAt: updatedDemandRequirement.updatedAt,
|
||||
} as AllocationWithDetails,
|
||||
strategy: "explicit_demand",
|
||||
};
|
||||
}
|
||||
|
||||
const updatedAssignment = await updateAssignment(
|
||||
db as Parameters<typeof updateAssignment>[0],
|
||||
resolved.assignment.id,
|
||||
input.assignmentUpdate,
|
||||
);
|
||||
|
||||
return {
|
||||
allocation: {
|
||||
...resolved.entry,
|
||||
...updatedAssignment,
|
||||
id: resolved.entry.id,
|
||||
resourceId: updatedAssignment.resourceId,
|
||||
isPlaceholder: false,
|
||||
headcount: 1,
|
||||
project: updatedAssignment.project,
|
||||
resource: updatedAssignment.resource ?? null,
|
||||
roleEntity: updatedAssignment.roleEntity ?? null,
|
||||
updatedAt: updatedAssignment.updatedAt,
|
||||
} as AllocationWithDetails,
|
||||
strategy: "explicit_assignment",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { type UpdateAssignmentInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
type AssignmentWithRelations,
|
||||
} from "./create-assignment.js";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export async function updateAssignment(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
input: UpdateAssignmentInput,
|
||||
): Promise<AssignmentWithRelations> {
|
||||
const existing = await db.assignment.findUnique({
|
||||
where: { id },
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
|
||||
}
|
||||
|
||||
const updatedAssignment = await db.assignment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}),
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
...(input.demandRequirementId !== undefined
|
||||
? { demandRequirementId: input.demandRequirementId }
|
||||
: {}),
|
||||
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
|
||||
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
|
||||
...(input.role !== undefined ? { role: input.role } : {}),
|
||||
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
|
||||
...(input.dailyCostCents !== undefined ? { dailyCostCents: input.dailyCostCents } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
include: ASSIGNMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "Assignment",
|
||||
entityId: id,
|
||||
action: "UPDATE",
|
||||
changes: {
|
||||
before: {
|
||||
resourceId: existing.resourceId,
|
||||
projectId: existing.projectId,
|
||||
demandRequirementId: existing.demandRequirementId,
|
||||
startDate: existing.startDate,
|
||||
endDate: existing.endDate,
|
||||
hoursPerDay: existing.hoursPerDay,
|
||||
percentage: existing.percentage,
|
||||
role: existing.role,
|
||||
roleId: existing.roleId,
|
||||
dailyCostCents: existing.dailyCostCents,
|
||||
status: existing.status,
|
||||
},
|
||||
after: {
|
||||
resourceId: updatedAssignment.resourceId,
|
||||
projectId: updatedAssignment.projectId,
|
||||
demandRequirementId: updatedAssignment.demandRequirementId,
|
||||
startDate: updatedAssignment.startDate,
|
||||
endDate: updatedAssignment.endDate,
|
||||
hoursPerDay: updatedAssignment.hoursPerDay,
|
||||
percentage: updatedAssignment.percentage,
|
||||
role: updatedAssignment.role,
|
||||
roleId: updatedAssignment.roleId,
|
||||
dailyCostCents: updatedAssignment.dailyCostCents,
|
||||
status: updatedAssignment.status,
|
||||
},
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedAssignment;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PrismaClient, Prisma } from "@planarchy/db";
|
||||
import { type UpdateDemandRequirementInput } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
type DemandRequirementWithRelations,
|
||||
} from "./create-demand-requirement.js";
|
||||
|
||||
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export async function updateDemandRequirement(
|
||||
db: DbClient,
|
||||
id: string,
|
||||
input: UpdateDemandRequirementInput,
|
||||
): Promise<DemandRequirementWithRelations> {
|
||||
const existing = await db.demandRequirement.findUnique({
|
||||
where: { id },
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
||||
}
|
||||
|
||||
const updatedDemandRequirement = await db.demandRequirement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
|
||||
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
|
||||
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
|
||||
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
|
||||
...(input.role !== undefined ? { role: input.role } : {}),
|
||||
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
|
||||
...(input.headcount !== undefined ? { headcount: input.headcount } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
},
|
||||
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
entityType: "DemandRequirement",
|
||||
entityId: id,
|
||||
action: "UPDATE",
|
||||
changes: {
|
||||
before: {
|
||||
projectId: existing.projectId,
|
||||
startDate: existing.startDate,
|
||||
endDate: existing.endDate,
|
||||
hoursPerDay: existing.hoursPerDay,
|
||||
percentage: existing.percentage,
|
||||
role: existing.role,
|
||||
roleId: existing.roleId,
|
||||
headcount: existing.headcount,
|
||||
status: existing.status,
|
||||
},
|
||||
after: {
|
||||
projectId: updatedDemandRequirement.projectId,
|
||||
startDate: updatedDemandRequirement.startDate,
|
||||
endDate: updatedDemandRequirement.endDate,
|
||||
hoursPerDay: updatedDemandRequirement.hoursPerDay,
|
||||
percentage: updatedDemandRequirement.percentage,
|
||||
role: updatedDemandRequirement.role,
|
||||
roleId: updatedDemandRequirement.roleId,
|
||||
headcount: updatedDemandRequirement.headcount,
|
||||
status: updatedDemandRequirement.status,
|
||||
},
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedDemandRequirement;
|
||||
}
|
||||
Reference in New Issue
Block a user