rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled

rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-21 16:28:40 +02:00
committed by Hartmut
parent d9a7ec0338
commit b41c1d2501
943 changed files with 24548 additions and 16832 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@capakraken/engine",
"name": "@nexus/engine",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -15,11 +15,11 @@
"test:unit:watch": "vitest"
},
"dependencies": {
"@capakraken/shared": "workspace:*",
"@nexus/shared": "workspace:*",
"exceljs": "^4.4.0"
},
"devDependencies": {
"@capakraken/tsconfig": "workspace:*",
"@nexus/tsconfig": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.6.3",
"vitest": "^2.1.8",
@@ -1,4 +1,4 @@
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { describe, expect, it } from "vitest";
import { detectOverlaps, validateAvailability } from "../allocation/availability-validator.js";
@@ -24,12 +24,14 @@ describe("validateAvailability", () => {
});
it("detects conflict when combined hours exceed availability", () => {
const existing = [{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 6,
status: AllocationStatus.CONFIRMED,
}];
const existing = [
{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 6,
status: AllocationStatus.CONFIRMED,
},
];
const result = validateAvailability(
new Date("2025-01-06"),
@@ -44,12 +46,14 @@ describe("validateAvailability", () => {
});
it("passes when combined hours exactly match availability", () => {
const existing = [{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 4,
status: AllocationStatus.CONFIRMED,
}];
const existing = [
{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 4,
status: AllocationStatus.CONFIRMED,
},
];
const result = validateAvailability(
new Date("2025-01-06"),
@@ -63,12 +67,14 @@ describe("validateAvailability", () => {
});
it("ignores cancelled allocations", () => {
const existing = [{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 8,
status: AllocationStatus.CANCELLED,
}];
const existing = [
{
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
hoursPerDay: 8,
status: AllocationStatus.CANCELLED,
},
];
const result = validateAvailability(
new Date("2025-01-06"),
@@ -105,11 +111,9 @@ describe("detectOverlaps", () => {
};
it("detects overlapping allocations", () => {
const overlaps = detectOverlaps(
new Date("2025-01-08"),
new Date("2025-01-15"),
[existingAlloc],
);
const overlaps = detectOverlaps(new Date("2025-01-08"), new Date("2025-01-15"), [
existingAlloc,
]);
expect(overlaps).toContain("alloc-1");
});
@@ -134,11 +138,7 @@ describe("detectOverlaps", () => {
it("ignores cancelled allocations", () => {
const cancelled = { ...existingAlloc, status: AllocationStatus.CANCELLED };
const overlaps = detectOverlaps(
new Date("2025-01-08"),
new Date("2025-01-15"),
[cancelled],
);
const overlaps = detectOverlaps(new Date("2025-01-08"), new Date("2025-01-15"), [cancelled]);
expect(overlaps).toHaveLength(0);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { FieldType, type BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType, type BlueprintFieldDefinition } from "@nexus/shared";
import {
isSuspectRegexPattern,
validateCustomFields,
+36 -8
View File
@@ -1,4 +1,4 @@
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { describe, expect, it } from "vitest";
import { computeBudgetStatus } from "../budget/monitor.js";
@@ -28,7 +28,11 @@ describe("computeBudgetStatus", () => {
it("calculates correct utilization", () => {
// 1 alloc, 10 working days, 8000 cents/day = 80000 cents
const alloc = { ...mkAlloc(8, 8000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const alloc = {
...mkAlloc(8, 8000),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-17"),
};
const result = computeBudgetStatus(400000, 100, [alloc], now, endDate);
expect(result.confirmedCents).toBe(80000);
expect(result.utilizationPercent).toBeCloseTo(20, 0);
@@ -36,36 +40,60 @@ describe("computeBudgetStatus", () => {
it("emits WARNING at 85% utilization", () => {
// Budget 100000, alloc cost 87000 = 87%
const alloc = { ...mkAlloc(8, 87000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const alloc = {
...mkAlloc(8, 87000 / 10),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-17"),
};
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
const hasWarning = result.warnings.some((w) => w.code === "BUDGET_WARNING");
expect(hasWarning).toBe(true);
});
it("emits CRITICAL at 95% utilization", () => {
const alloc = { ...mkAlloc(8, 96000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const alloc = {
...mkAlloc(8, 96000 / 10),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-17"),
};
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
const hasCritical = result.warnings.some((w) => w.code === "BUDGET_CRITICAL");
expect(hasCritical).toBe(true);
});
it("emits EXCEEDED when allocations exceed budget", () => {
const alloc = { ...mkAlloc(8, 120000 / 10), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const alloc = {
...mkAlloc(8, 120000 / 10),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-17"),
};
const result = computeBudgetStatus(100000, 100, [alloc], now, endDate);
const hasExceeded = result.warnings.some((w) => w.code === "BUDGET_EXCEEDED");
expect(hasExceeded).toBe(true);
});
it("applies win probability weighting", () => {
const alloc = { ...mkAlloc(8, 10000), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-17") };
const alloc = {
...mkAlloc(8, 10000),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-17"),
};
const result = computeBudgetStatus(1000000, 50, [alloc], now, endDate);
// allocated = 10 days × 10000 = 100000, weighted = 50% = 50000
expect(result.winProbabilityWeightedCents).toBe(Math.round(result.allocatedCents * 0.5));
});
it("separates proposed from confirmed costs", () => {
const confirmed = { ...mkAlloc(8, 5000, AllocationStatus.CONFIRMED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
const proposed = { ...mkAlloc(8, 5000, AllocationStatus.PROPOSED), startDate: new Date("2025-01-06"), endDate: new Date("2025-01-10") };
const confirmed = {
...mkAlloc(8, 5000, AllocationStatus.CONFIRMED),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
};
const proposed = {
...mkAlloc(8, 5000, AllocationStatus.PROPOSED),
startDate: new Date("2025-01-06"),
endDate: new Date("2025-01-10"),
};
const result = computeBudgetStatus(1000000, 100, [confirmed, proposed], now, endDate);
expect(result.confirmedCents).toBeGreaterThan(0);
expect(result.proposedCents).toBeGreaterThan(0);
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { calculateAllocation } from "../allocation/calculator.js";
import { DEFAULT_CALCULATION_RULES } from "../rules/default-rules.js";
import type { WeekdayAvailability, AllocationCalculationInput } from "@capakraken/shared";
import type { CalculationRule } from "@capakraken/shared";
import type { WeekdayAvailability, AllocationCalculationInput } from "@nexus/shared";
import type { CalculationRule } from "@nexus/shared";
const STD_AVAILABILITY: WeekdayAvailability = {
monday: 8,
@@ -5,7 +5,7 @@ import {
defaultCommercialTerms,
validatePaymentMilestones,
} from "../estimate/commercial-terms.js";
import type { CommercialTerms, PaymentMilestone } from "@capakraken/shared";
import type { CommercialTerms, PaymentMilestone } from "@nexus/shared";
const BASE_TERMS: CommercialTerms = {
pricingModel: "fixed_price",
@@ -205,9 +205,7 @@ describe("computeMilestoneAmounts", () => {
});
it("preserves due dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "M1", percent: 100, dueDate: "2026-06-15" },
];
const milestones: PaymentMilestone[] = [{ label: "M1", percent: 100, dueDate: "2026-06-15" }];
const amounts = computeMilestoneAmounts(50_000_00, milestones);
expect(amounts[0]!.dueDate).toBe("2026-06-15");
});
@@ -1,8 +1,4 @@
import {
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@capakraken/shared";
import { EstimateExportFormat, EstimateStatus, EstimateVersionStatus } from "@nexus/shared";
import { describe, expect, it } from "vitest";
import {
serializeEstimateExport,
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { isRecurringDay, getRecurringHoursForDay } from "../allocation/recurrence.js";
import { RecurrenceFrequency } from "@capakraken/shared";
import type { RecurrencePattern } from "@capakraken/shared";
import { RecurrenceFrequency } from "@nexus/shared";
import type { RecurrencePattern } from "@nexus/shared";
const monday = new Date("2026-03-09"); // Monday
const tuesday = new Date("2026-03-10"); // Tuesday
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { findMatchingRule, applyCostEffect } from "../rules/engine.js";
import { DEFAULT_CALCULATION_RULES } from "../rules/default-rules.js";
import type { CalculationRule } from "@capakraken/shared";
import type { CalculationRule } from "@nexus/shared";
const now = new Date();
@@ -46,7 +46,12 @@ describe("findMatchingRule", () => {
it("prefers more specific rules (projectId match)", () => {
const global = makeRule({ id: "global", triggerType: "SICK", projectId: null });
const specific = makeRule({ id: "specific", triggerType: "SICK", projectId: "proj_1", costEffect: "CHARGE" });
const specific = makeRule({
id: "specific",
triggerType: "SICK",
projectId: "proj_1",
costEffect: "CHARGE",
});
const match = findMatchingRule([global, specific], "SICK", "proj_1");
expect(match!.rule.id).toBe("specific");
expect(match!.costEffect).toBe("CHARGE");
@@ -73,7 +78,12 @@ describe("findMatchingRule", () => {
});
it("matches orderType filter", () => {
const rule = makeRule({ triggerType: "VACATION", orderType: "CHARGEABLE" as never, costEffect: "REDUCE", costReductionPercent: 50 });
const rule = makeRule({
triggerType: "VACATION",
orderType: "CHARGEABLE" as never,
costEffect: "REDUCE",
costReductionPercent: 50,
});
const match = findMatchingRule([rule], "VACATION", null, "CHARGEABLE");
expect(match).not.toBeNull();
expect(match!.costEffect).toBe("REDUCE");
@@ -87,7 +97,13 @@ describe("findMatchingRule", () => {
it("specificity: projectId + orderType > projectId only", () => {
const projOnly = makeRule({ id: "proj", triggerType: "SICK", projectId: "p1" });
const both = makeRule({ id: "both", triggerType: "SICK", projectId: "p1", orderType: "CHARGEABLE" as never, costEffect: "REDUCE" });
const both = makeRule({
id: "both",
triggerType: "SICK",
projectId: "p1",
orderType: "CHARGEABLE" as never,
costEffect: "REDUCE",
});
const match = findMatchingRule([projOnly, both], "SICK", "p1", "CHARGEABLE");
expect(match!.rule.id).toBe("both");
});
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { calculateSAH, getDailyHours } from "../sah/calculator.js";
import type { SpainScheduleRule } from "@capakraken/shared";
import type { SpainScheduleRule } from "@nexus/shared";
const spainRules: SpainScheduleRule = {
type: "spain",
@@ -1,4 +1,4 @@
import type { Allocation, WeekdayAvailability } from "@capakraken/shared";
import type { Allocation, WeekdayAvailability } from "@nexus/shared";
import { getAvailableHoursForDate } from "./calculator.js";
export interface AvailabilityConflict {
+23 -10
View File
@@ -4,8 +4,8 @@ import type {
AllocationCalculationResult,
DailyBreakdown,
WeekdayAvailability,
} from "@capakraken/shared";
import type { AbsenceTrigger } from "@capakraken/shared";
} from "@nexus/shared";
import type { AbsenceTrigger } from "@nexus/shared";
import { getRecurringHoursForDay } from "./recurrence.js";
import { findMatchingRule, applyCostEffect } from "../rules/engine.js";
@@ -24,10 +24,7 @@ const DOW_KEYS: (keyof WeekdayAvailability)[] = [
* Returns the availability hours for a given date.
* Returns 0 for days not in the availability map (treated as non-working).
*/
export function getAvailableHoursForDate(
date: Date,
availability: WeekdayAvailability,
): number {
export function getAvailableHoursForDate(date: Date, availability: WeekdayAvailability): number {
const key = DOW_KEYS[date.getDay()];
if (!key) return 0;
return availability[key] ?? 0;
@@ -72,10 +69,22 @@ export function countWorkingDays(
*
* Monetary values always in integer cents.
*/
export function calculateAllocation(input: AllocationCalculationInput): AllocationCalculationResult {
export function calculateAllocation(
input: AllocationCalculationInput,
): AllocationCalculationResult {
const {
lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday,
recurrence, vacationDates, absenceDays, calculationRules, orderType, projectId,
lcrCents,
hoursPerDay,
startDate,
endDate,
availability,
includeSaturday,
recurrence,
vacationDates,
absenceDays,
calculationRules,
orderType,
projectId,
} = input;
// When includeSaturday is not explicitly true, zero out saturday availability
@@ -158,7 +167,11 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
if (match) {
// Cost effect: how much does the project pay?
const normalCostCents = Math.round(absentHours * lcrCents);
const absentProjectCost = applyCostEffect(normalCostCents, match.costEffect, match.costReductionPercent);
const absentProjectCost = applyCostEffect(
normalCostCents,
match.costEffect,
match.costReductionPercent,
);
const workedCostCents = Math.round(workedHours * lcrCents);
projectCostCents = workedCostCents + absentProjectCost;
+13 -10
View File
@@ -1,4 +1,4 @@
import { DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared";
import { DAY_KEYS, type WeekdayAvailability } from "@nexus/shared";
export interface ChargeabilityAllocation {
startDate: Date;
@@ -39,13 +39,17 @@ export function computeBookedHours(
end: Date,
): number {
let hours = 0;
const startNorm = new Date(start); startNorm.setHours(0, 0, 0, 0);
const endNorm = new Date(end); endNorm.setHours(0, 0, 0, 0);
const startNorm = new Date(start);
startNorm.setHours(0, 0, 0, 0);
const endNorm = new Date(end);
endNorm.setHours(0, 0, 0, 0);
for (const alloc of allocations) {
const aStart = new Date(alloc.startDate); aStart.setHours(0, 0, 0, 0);
const aEnd = new Date(alloc.endDate); aEnd.setHours(0, 0, 0, 0);
const aStart = new Date(alloc.startDate);
aStart.setHours(0, 0, 0, 0);
const aEnd = new Date(alloc.endDate);
aEnd.setHours(0, 0, 0, 0);
const overlapStart = aStart > startNorm ? aStart : startNorm;
const overlapEnd = aEnd < endNorm ? aEnd : endNorm;
const overlapEnd = aEnd < endNorm ? aEnd : endNorm;
if (overlapStart > overlapEnd) continue;
const cur = new Date(overlapStart);
while (cur <= overlapEnd) {
@@ -67,9 +71,8 @@ export function computeChargeability(
end: Date,
): ChargeabilityResult {
const availableHours = computeAvailableHours(availability, start, end);
const bookedHours = computeBookedHours(availability, allocations, start, end);
const chargeability = availableHours > 0
? Math.min(100, Math.round((bookedHours / availableHours) * 100))
: 0;
const bookedHours = computeBookedHours(availability, allocations, start, end);
const chargeability =
availableHours > 0 ? Math.min(100, Math.round((bookedHours / availableHours) * 100)) : 0;
return { availableHours, bookedHours, chargeability };
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { RecurrencePattern } from "@capakraken/shared";
import { RecurrenceFrequency } from "@capakraken/shared";
import type { RecurrencePattern } from "@nexus/shared";
import { RecurrenceFrequency } from "@nexus/shared";
/**
* Returns the ISO week number of a date relative to a base date.
+1 -1
View File
@@ -1,4 +1,4 @@
import { FieldType, type BlueprintFieldDefinition } from "@capakraken/shared";
import { FieldType, type BlueprintFieldDefinition } from "@nexus/shared";
export interface CustomFieldValidationError {
key: string;
+10 -7
View File
@@ -1,5 +1,5 @@
import type { Allocation, BudgetStatus, BudgetWarning } from "@capakraken/shared";
import { BUDGET_WARNING_THRESHOLDS } from "@capakraken/shared";
import type { Allocation, BudgetStatus, BudgetWarning } from "@nexus/shared";
import { BUDGET_WARNING_THRESHOLDS } from "@nexus/shared";
/**
* Computes budget status for a project given its allocations.
@@ -8,7 +8,10 @@ import { BUDGET_WARNING_THRESHOLDS } from "@capakraken/shared";
export function computeBudgetStatus(
budgetCents: number,
winProbability: number,
allocations: (Pick<Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"> & {
allocations: (Pick<
Allocation,
"status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"
> & {
/** When provided (from rules engine), used instead of dailyCostCents * days */
adjustedTotalCostCents?: number;
})[],
@@ -22,10 +25,10 @@ export function computeBudgetStatus(
let proposedCents = 0;
for (const alloc of allocations) {
const totalCents = alloc.adjustedTotalCostCents ?? (alloc.dailyCostCents * countWorkingDaysInRange(
new Date(alloc.startDate),
new Date(alloc.endDate),
));
const totalCents =
alloc.adjustedTotalCostCents ??
alloc.dailyCostCents *
countWorkingDaysInRange(new Date(alloc.startDate), new Date(alloc.endDate));
if (activeStatuses.has(alloc.status)) {
confirmedCents += totalCents;
@@ -5,7 +5,7 @@
* to base cost/price totals from demand lines.
*/
import type { CommercialTerms, CommercialTermsSummary, PaymentMilestone } from "@capakraken/shared";
import type { CommercialTerms, CommercialTermsSummary, PaymentMilestone } from "@nexus/shared";
export interface CommercialTermsInput {
baseCostCents: number;
@@ -23,9 +23,7 @@ export interface CommercialTermsInput {
* adjustedPrice = basePrice * (1 - discount%)
* margin = adjustedPrice - adjustedCost
*/
export function computeCommercialTermsSummary(
input: CommercialTermsInput,
): CommercialTermsSummary {
export function computeCommercialTermsSummary(input: CommercialTermsInput): CommercialTermsSummary {
const { baseCostCents, basePriceCents, terms } = input;
const contingencyFactor = terms.contingencyPercent / 100;
@@ -38,9 +36,7 @@ export function computeCommercialTermsSummary(
const adjustedPriceCents = basePriceCents - discountCents;
const adjustedMarginCents = adjustedPriceCents - adjustedCostCents;
const adjustedMarginPercent =
adjustedPriceCents > 0
? (adjustedMarginCents / adjustedPriceCents) * 100
: 0;
adjustedPriceCents > 0 ? (adjustedMarginCents / adjustedPriceCents) * 100 : 0;
return {
baseCostCents,
@@ -58,9 +54,7 @@ export function computeCommercialTermsSummary(
* Validate that payment milestones sum to 100%.
* Returns list of validation warnings (empty = valid).
*/
export function validatePaymentMilestones(
milestones: PaymentMilestone[],
): string[] {
export function validatePaymentMilestones(milestones: PaymentMilestone[]): string[] {
const warnings: string[] = [];
if (milestones.length === 0) return warnings;
@@ -68,9 +62,7 @@ export function validatePaymentMilestones(
const totalPercent = milestones.reduce((sum, m) => sum + m.percent, 0);
if (Math.abs(totalPercent - 100) > 0.01) {
warnings.push(
`Payment milestones sum to ${totalPercent.toFixed(1)}%, expected 100%`,
);
warnings.push(`Payment milestones sum to ${totalPercent.toFixed(1)}%, expected 100%`);
}
for (let i = 0; i < milestones.length; i++) {
@@ -85,8 +77,7 @@ export function validatePaymentMilestones(
// Check chronological order when dates are provided
const datedMilestones = milestones.filter(
(m): m is PaymentMilestone & { dueDate: string } =>
m.dueDate != null && m.dueDate !== "",
(m): m is PaymentMilestone & { dueDate: string } => m.dueDate != null && m.dueDate !== "",
);
for (let i = 1; i < datedMilestones.length; i++) {
if (datedMilestones[i]!.dueDate < datedMilestones[i - 1]!.dueDate) {
@@ -4,7 +4,7 @@ import {
type EstimateExportSummary,
type EstimateStatus,
type EstimateVersionStatus,
} from "@capakraken/shared";
} from "@nexus/shared";
import { summarizeEstimateDemandLines } from "./metrics.js";
type ExcelJsModule = typeof import("exceljs");
@@ -200,10 +200,7 @@ function escapeDelimitedValue(value: unknown, delimiter: string) {
return rendered;
}
function serializeDelimited(
rows: Array<Record<string, unknown>>,
delimiter: string,
) {
function serializeDelimited(rows: Array<Record<string, unknown>>, delimiter: string) {
if (rows.length === 0) {
return "";
}
@@ -226,18 +223,12 @@ function serializeDelimited(
function buildSummary(source: EstimateExportSource): EstimateExportSummary {
const summarized = summarizeEstimateDemandLines(source.version.demandLines);
const metricsByKey = new Map(
source.version.metrics.map((metric) => [metric.key, metric]),
);
const metricsByKey = new Map(source.version.metrics.map((metric) => [metric.key, metric]));
const totalHours =
metricsByKey.get("total_hours")?.valueDecimal ?? summarized.totalHours;
const totalCostCents =
metricsByKey.get("total_cost")?.valueCents ?? summarized.totalCostCents;
const totalPriceCents =
metricsByKey.get("total_price")?.valueCents ?? summarized.totalPriceCents;
const marginCents =
metricsByKey.get("margin")?.valueCents ?? summarized.marginCents;
const totalHours = metricsByKey.get("total_hours")?.valueDecimal ?? summarized.totalHours;
const totalCostCents = metricsByKey.get("total_cost")?.valueCents ?? summarized.totalCostCents;
const totalPriceCents = metricsByKey.get("total_price")?.valueCents ?? summarized.totalPriceCents;
const marginCents = metricsByKey.get("margin")?.valueCents ?? summarized.marginCents;
const marginPercent =
metricsByKey.get("margin_percent")?.valueDecimal ?? summarized.marginPercent;
@@ -262,10 +253,7 @@ function buildSummary(source: EstimateExportSource): EstimateExportSummary {
};
}
function buildOverviewRows(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
function buildOverviewRows(source: EstimateExportSource, summary: EstimateExportSummary) {
return [
{ field: "estimate_id", value: summary.estimateId },
{ field: "estimate_name", value: summary.estimateName },
@@ -281,7 +269,10 @@ function buildOverviewRows(
{ field: "base_currency", value: summary.baseCurrency },
{ field: "opportunity_id", value: source.estimate.opportunityId ?? "" },
{ field: "locked_at", value: serializeDate(source.version.lockedAt) ?? "" },
{ field: "generated_from_project_start", value: serializeDate(source.project?.startDate) ?? "" },
{
field: "generated_from_project_start",
value: serializeDate(source.project?.startDate) ?? "",
},
{ field: "generated_from_project_end", value: serializeDate(source.project?.endDate) ?? "" },
{ field: "assumption_count", value: summary.assumptionCount },
{ field: "scope_item_count", value: summary.scopeItemCount },
@@ -339,8 +330,7 @@ function buildDemandRows(source: EstimateExportSource) {
line_no: index + 1,
line_id: line.id,
scope_item_id: line.scopeItemId ?? "",
scope_item_name:
(line.scopeItemId ? scopeItemsById.get(line.scopeItemId)?.name : null) ?? "",
scope_item_name: (line.scopeItemId ? scopeItemsById.get(line.scopeItemId)?.name : null) ?? "",
role_id: line.roleId ?? "",
resource_id: line.resourceId ?? "",
line_type: line.lineType,
@@ -394,10 +384,7 @@ function buildMetricRows(metrics: ExportMetric[]) {
}));
}
function buildSapRows(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
function buildSapRows(source: EstimateExportSource, summary: EstimateExportSummary) {
return source.version.demandLines.map((line, index) => ({
record_type: "ESTIMATE_LINE",
estimate_id: summary.estimateId,
@@ -420,10 +407,7 @@ function buildSapRows(
}));
}
function buildMmpRows(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
function buildMmpRows(source: EstimateExportSource, summary: EstimateExportSummary) {
const monthKeys = Array.from(
new Set(
source.version.demandLines.flatMap((line) =>
@@ -449,18 +433,14 @@ function buildMmpRows(
};
for (const monthKey of monthKeys) {
baseRow[`month_${monthKey}`] =
toNumericRecord(line.monthlySpread)[monthKey] ?? 0;
baseRow[`month_${monthKey}`] = toNumericRecord(line.monthlySpread)[monthKey] ?? 0;
}
return baseRow;
});
}
function buildJsonDocument(
source: EstimateExportSource,
summary: EstimateExportSummary,
) {
function buildJsonDocument(source: EstimateExportSource, summary: EstimateExportSummary) {
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
@@ -626,8 +606,7 @@ async function buildXlsxPayload(
return {
schemaVersion: 1,
format: EstimateExportFormat.XLSX,
mimeType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
encoding: "base64",
fileExtension: "xlsx",
generatedAt: new Date().toISOString(),
+7 -20
View File
@@ -3,7 +3,7 @@ import type {
EstimateDemandLineCalculationMetadata,
EstimateDemandLineRateMode,
EstimateDemandSummary,
} from "@capakraken/shared";
} from "@nexus/shared";
export interface EstimateDemandLineRateSnapshot {
resourceId?: string | null;
@@ -36,9 +36,7 @@ function parseDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
): ParsedDemandLineMetadata {
const safeMetadata =
typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
? metadata
: {};
typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? metadata : {};
const rawCalculation =
typeof safeMetadata.calculation === "object" &&
safeMetadata.calculation !== null &&
@@ -156,8 +154,7 @@ export function normalizeEstimateDemandLine<T extends EstimateDemandLineForCalcu
? resourceSnapshot.ucrCents
: line.billRateCents;
const currency =
((calculation.costRateMode === "resource" ||
calculation.billRateMode === "resource") &&
((calculation.costRateMode === "resource" || calculation.billRateMode === "resource") &&
resourceSnapshot?.currency
? resourceSnapshot.currency
: line.currency) ||
@@ -181,23 +178,13 @@ export function normalizeEstimateDemandLine<T extends EstimateDemandLineForCalcu
}
export function summarizeEstimateDemandLines(
demandLines: Pick<
EstimateDemandLine,
"hours" | "costTotalCents" | "priceTotalCents"
>[],
demandLines: Pick<EstimateDemandLine, "hours" | "costTotalCents" | "priceTotalCents">[],
): EstimateDemandSummary {
const totalHours = demandLines.reduce((sum, line) => sum + line.hours, 0);
const totalCostCents = demandLines.reduce(
(sum, line) => sum + line.costTotalCents,
0,
);
const totalPriceCents = demandLines.reduce(
(sum, line) => sum + line.priceTotalCents,
0,
);
const totalCostCents = demandLines.reduce((sum, line) => sum + line.costTotalCents, 0);
const totalPriceCents = demandLines.reduce((sum, line) => sum + line.priceTotalCents, 0);
const marginCents = totalPriceCents - totalCostCents;
const marginPercent =
totalPriceCents > 0 ? Math.round((marginCents / totalPriceCents) * 100) : 0;
const marginPercent = totalPriceCents > 0 ? Math.round((marginCents / totalPriceCents) * 100) : 0;
return {
totalHours,
@@ -10,7 +10,7 @@
* - Aggregation by chapter for 4Dispo view
*/
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { MILLISECONDS_PER_DAY } from "@nexus/shared";
export interface WeekDefinition {
weekNumber: number;
@@ -193,7 +193,7 @@ function distributeLoadedPattern(
for (let i = 0; i < n; i++) {
const week = weeks[i]!;
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
weeklyHours[key] = Math.round((totalHours * (weights[i]! / totalWeight)) * 100) / 100;
weeklyHours[key] = Math.round(totalHours * (weights[i]! / totalWeight) * 100) / 100;
}
adjustRoundingError(weeklyHours, weeks, totalHours);
+1 -1
View File
@@ -7,7 +7,7 @@
* - Public holiday: no chargeability effect, no project cost
*/
import type { CalculationRule } from "@capakraken/shared";
import type { CalculationRule } from "@nexus/shared";
const now = new Date();
+2 -2
View File
@@ -10,7 +10,7 @@ import type {
CalculationRule,
CostEffect,
ChargeabilityEffect,
} from "@capakraken/shared";
} from "@nexus/shared";
export interface RuleMatch {
rule: CalculationRule;
@@ -86,6 +86,6 @@ export function applyCostEffect(
case "ZERO":
return 0;
case "REDUCE":
return Math.round(normalCostCents * (100 - (reductionPercent ?? 0)) / 100);
return Math.round((normalCostCents * (100 - (reductionPercent ?? 0))) / 100);
}
}
+13 -4
View File
@@ -5,8 +5,8 @@
* It is the denominator for chargeability calculations.
*/
import { toIsoDate } from "@capakraken/shared";
import type { SpainScheduleRule } from "@capakraken/shared";
import { toIsoDate } from "@nexus/shared";
import type { SpainScheduleRule } from "@nexus/shared";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -87,7 +87,15 @@ export function getDailyHours(
// ─── Calculator ─────────────────────────────────────────────────────────────
export function calculateSAH(input: SAHInput): SAHResult {
const { dailyWorkingHours, scheduleRules, fte, periodStart, periodEnd, publicHolidays, absenceDays } = input;
const {
dailyWorkingHours,
scheduleRules,
fte,
periodStart,
periodEnd,
publicHolidays,
absenceDays,
} = input;
const holidaySet = new Set(publicHolidays.map(toIsoDate));
const absenceSet = new Set(absenceDays.map(toIsoDate));
@@ -125,7 +133,8 @@ export function calculateSAH(input: SAHInput): SAHResult {
}
const grossWorkingDays = calendarDays - weekendDays;
const effectiveHoursPerDay = netWorkingDays > 0 ? totalHoursOnWorkingDays / netWorkingDays : dailyWorkingHours * fte;
const effectiveHoursPerDay =
netWorkingDays > 0 ? totalHoursOnWorkingDays / netWorkingDays : dailyWorkingHours * fte;
return {
calendarDays,
+22 -7
View File
@@ -6,7 +6,7 @@ import type {
ShiftValidationResult,
ValidationError,
ValidationWarning,
} from "@capakraken/shared";
} from "@nexus/shared";
import { calculateAllocation } from "../allocation/calculator.js";
import { validateAvailability } from "../allocation/availability-validator.js";
import { computeBudgetStatus } from "../budget/monitor.js";
@@ -23,10 +23,21 @@ export interface ShiftInput {
newEndDate: Date;
allocations: (Pick<
Allocation,
"id" | "resourceId" | "startDate" | "endDate" | "hoursPerDay" | "percentage" | "role" | "dailyCostCents" | "status"
| "id"
| "resourceId"
| "startDate"
| "endDate"
| "hoursPerDay"
| "percentage"
| "role"
| "dailyCostCents"
| "status"
> & {
resource: Pick<Resource, "id" | "displayName" | "lcrCents" | "availability">;
allAllocationsForResource: Pick<Allocation, "id" | "startDate" | "endDate" | "hoursPerDay" | "status" | "projectId">[];
allAllocationsForResource: Pick<
Allocation,
"id" | "startDate" | "endDate" | "hoursPerDay" | "status" | "projectId"
>[];
/** Extracted from allocation metadata before calling validator */
includeSaturday?: boolean;
})[];
@@ -54,7 +65,13 @@ export function validateShift(input: ShiftInput): ShiftValidationResult {
message: "New end date must be after new start date",
field: "newEndDate",
});
return buildResult(false, errors, warnings, conflictDetails, buildZeroCostImpact(project.budgetCents));
return buildResult(
false,
errors,
warnings,
conflictDetails,
buildZeroCostImpact(project.budgetCents),
);
}
// Warn if duration changed significantly
@@ -90,9 +107,7 @@ export function validateShift(input: ShiftInput): ShiftValidationResult {
const shiftedEnd = new Date(newEndDate);
// Validate availability for shifted period (excluding current project's allocations)
const otherAllocations = allAllocationsForResource.filter(
(a) => a.projectId !== project.id,
);
const otherAllocations = allAllocationsForResource.filter((a) => a.projectId !== project.id);
const availResult = validateAvailability(
shiftedStart,
shiftedEnd,
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@capakraken/tsconfig/base.json",
"extends": "@nexus/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"