feat: Sprint 3 — automation, intelligence, skill marketplace
Auto-Staffing Suggestions (A6): - generateAutoSuggestions() ranks top-3 resources on demand creation - Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%) - Creates in-app notification with match scores for managers - Triggered after createDemandRequirement and partial fillDemandRequirement Vacation Conflict Detection (A7): - checkVacationConflicts() warns when >50% chapter absent on same days - Returns warnings array in approve/batchApprove responses (advisory, non-blocking) - Creates VACATION_CONFLICT_WARNING notification for approver Weekly Chargeability Alerts (A10): - checkChargeabilityAlerts() finds resources >15pp below target - Cron endpoint: GET /api/cron/chargeability-alerts - Duplicate-safe by resourceId + month composite key Rate Card Auto-Apply (A11): - lookupRate() finds best matching rate card line (weighted scoring) - Auto-fills demand line rates in estimate create/updateDraft when rates are 0 - Marks auto-filled lines with metadata.autoAppliedRateCard - New lookupDemandLineRate query for on-demand UI lookups Public Holiday Auto-Import (A12): - autoImportPublicHolidays() generates holidays by resource federal state - Cron endpoint: GET /api/cron/public-holidays?year=2027 - Duplicate-safe, uses existing getPublicHolidays() from shared Skill Marketplace MVP (G6): - New page: /analytics/skill-marketplace with 3 sections - Skill Search: filter by name, proficiency, availability, sortable results - Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators - Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart) - New getSkillMarketplace query in resource router - Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { lookupRate } from "../lib/rate-card-lookup.js";
|
||||
import {
|
||||
controllerProcedure,
|
||||
createTRPCRouter,
|
||||
@@ -142,6 +143,75 @@ function withComputedMetrics<
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fill rate card rates into demand lines that have default (zero) rates.
|
||||
* A line is eligible for auto-fill when both costRateCents and billRateCents
|
||||
* are 0 (the Zod default) and rateSource is not explicitly set.
|
||||
*
|
||||
* Returns the enriched demand lines and a list of line indices that were auto-filled.
|
||||
*/
|
||||
async function autoFillDemandLineRates(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: any,
|
||||
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
|
||||
projectId?: string | null,
|
||||
): Promise<{
|
||||
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
|
||||
autoFilledIndices: number[];
|
||||
}> {
|
||||
// Resolve clientId from the linked project
|
||||
let clientId: string | null = null;
|
||||
if (projectId) {
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { clientId: true },
|
||||
});
|
||||
clientId = project?.clientId ?? null;
|
||||
}
|
||||
|
||||
const autoFilledIndices: number[] = [];
|
||||
|
||||
const enriched = await Promise.all(
|
||||
demandLines.map(async (line, index) => {
|
||||
// Only auto-fill if both rates are at default (0) and no explicit rateSource
|
||||
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
|
||||
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
|
||||
|
||||
if (!isDefaultRate || hasExplicitSource) return line;
|
||||
|
||||
const result = await lookupRate(db, {
|
||||
clientId,
|
||||
chapter: line.chapter ?? null,
|
||||
roleId: line.roleId ?? null,
|
||||
});
|
||||
|
||||
if (!result) return line;
|
||||
|
||||
autoFilledIndices.push(index);
|
||||
|
||||
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...line,
|
||||
costRateCents: result.costRateCents,
|
||||
billRateCents: result.billRateCents,
|
||||
currency: result.currency,
|
||||
rateSource: `rate-card:${result.rateCardId}`,
|
||||
metadata: {
|
||||
...existingMetadata,
|
||||
autoAppliedRateCard: {
|
||||
rateCardId: result.rateCardId,
|
||||
rateCardLineId: result.rateCardLineId,
|
||||
rateCardName: result.rateCardName,
|
||||
appliedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { demandLines: enriched, autoFilledIndices };
|
||||
}
|
||||
|
||||
export const estimateRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(EstimateListFiltersSchema.default({}))
|
||||
@@ -180,9 +250,14 @@ export const estimateRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-fill rates from rate cards for demand lines with default (zero) rates
|
||||
const { demandLines: enrichedLines, autoFilledIndices } =
|
||||
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
|
||||
const enrichedInput = { ...input, demandLines: enrichedLines };
|
||||
|
||||
const estimate = await createEstimate(
|
||||
ctx.db as unknown as Parameters<typeof createEstimate>[0],
|
||||
withComputedMetrics(input, input.baseCurrency),
|
||||
withComputedMetrics(enrichedInput, input.baseCurrency),
|
||||
);
|
||||
|
||||
await ctx.db.auditLog.create({
|
||||
@@ -198,6 +273,7 @@ export const estimateRouter = createTRPCRouter({
|
||||
status: estimate.status,
|
||||
projectId: estimate.projectId,
|
||||
latestVersionNumber: estimate.latestVersionNumber,
|
||||
autoFilledRateCardLines: autoFilledIndices.length,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
@@ -263,11 +339,25 @@ export const estimateRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-fill rates from rate cards for demand lines with default (zero) rates
|
||||
// Resolve projectId: explicit input or existing estimate's projectId
|
||||
let effectiveProjectId = input.projectId;
|
||||
if (!effectiveProjectId) {
|
||||
const existing = await ctx.db.estimate.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { projectId: true },
|
||||
});
|
||||
effectiveProjectId = existing?.projectId ?? undefined;
|
||||
}
|
||||
const { demandLines: enrichedLines, autoFilledIndices } =
|
||||
await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
|
||||
const enrichedInput = { ...input, demandLines: enrichedLines };
|
||||
|
||||
let estimate;
|
||||
try {
|
||||
estimate = await updateEstimateDraft(
|
||||
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
|
||||
withComputedMetrics(input, input.baseCurrency ?? "EUR"),
|
||||
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "Estimate not found") {
|
||||
@@ -300,6 +390,7 @@ export const estimateRouter = createTRPCRouter({
|
||||
workingVersionId: estimate.versions.find(
|
||||
(version) => version.status === "WORKING",
|
||||
)?.id,
|
||||
autoFilledRateCardLines: autoFilledIndices.length,
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
@@ -837,4 +928,51 @@ export const estimateRouter = createTRPCRouter({
|
||||
|
||||
return { versionId: version.id, terms: validated };
|
||||
}),
|
||||
|
||||
// ─── Rate Card Lookup for Demand Lines ──────────────────────────────────
|
||||
|
||||
lookupDemandLineRate: controllerProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string().optional(),
|
||||
clientId: z.string().optional(),
|
||||
roleId: z.string().optional(),
|
||||
chapter: z.string().optional(),
|
||||
seniority: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
workType: z.string().optional(),
|
||||
effectiveDate: z.coerce.date().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Resolve clientId from project if not provided directly
|
||||
let clientId = input.clientId ?? null;
|
||||
if (!clientId && input.projectId) {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: { clientId: true },
|
||||
});
|
||||
clientId = project?.clientId ?? null;
|
||||
}
|
||||
|
||||
const result = await lookupRate(ctx.db, {
|
||||
clientId,
|
||||
chapter: input.chapter ?? null,
|
||||
roleId: input.roleId ?? null,
|
||||
seniority: input.seniority ?? null,
|
||||
location: input.location ?? null,
|
||||
workType: input.workType ?? null,
|
||||
effectiveDate: input.effectiveDate ?? null,
|
||||
});
|
||||
|
||||
if (!result) return { found: false as const };
|
||||
|
||||
return {
|
||||
found: true as const,
|
||||
costRateCents: result.costRateCents,
|
||||
billRateCents: result.billRateCents,
|
||||
currency: result.currency,
|
||||
rateCardId: result.rateCardId,
|
||||
rateCardLineId: result.rateCardLineId,
|
||||
rateCardName: result.rateCardName,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user