项目文件夹

文件
2026-07-13 12:58:18 +08:00

479 行
13 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { describe, it, expect } from "vitest";
import {
groupDealsByStage,
formatCurrency,
STAGES,
dealRisk,
computeKpis,
applyStageOverlay,
pruneOverlay,
relativeTime,
salesOverTime,
revenueByCategory,
teamLeaderboard,
teamStats,
repStats,
} from "./crm.js";
import type { Deal, CrmState, Product, Salesperson } from "./crm.js";
const d = (id: string, stage: Deal["stage"]): Deal => ({
id,
accountId: "a",
name: id,
amount: 1000,
stage,
probability: 50,
closeDate: "2026-01-01",
ownerName: "You",
ownerId: "s1",
lineItems: [],
});
describe("crm lib", () => {
it("groupDealsByStage buckets by stage in canonical order", () => {
const g = groupDealsByStage([d("x", "Proposal"), d("y", "Lead")]);
expect(Object.keys(g)).toEqual(STAGES);
expect(g["Lead"].map((x) => x.id)).toEqual(["y"]);
expect(g["Proposal"].map((x) => x.id)).toEqual(["x"]);
});
it("formatCurrency renders whole-dollar USD", () => {
expect(formatCurrency(42000)).toBe("$42,000");
});
});
const NOW = new Date("2026-06-03T00:00:00Z").getTime();
const mk = (over: Partial<Deal>): Deal => ({
id: "d",
accountId: "a",
name: "d",
amount: 10000,
stage: "Qualified",
probability: 50,
closeDate: "2026-09-01",
ownerName: "You",
ownerId: "s1",
lineItems: [],
...over,
});
describe("dealRisk", () => {
it("is low for closed deals regardless of dates", () => {
expect(
dealRisk(
mk({ stage: "Closed Won", probability: 0, closeDate: "2020-01-01" }),
NOW,
),
).toBe("low");
expect(dealRisk(mk({ stage: "Closed Lost" }), NOW)).toBe("low");
});
it("is high for low probability or past close on open deals", () => {
expect(dealRisk(mk({ probability: 20 }), NOW)).toBe("high");
expect(dealRisk(mk({ closeDate: "2026-05-01" }), NOW)).toBe("high");
});
it("is medium for mid probability or near close", () => {
expect(
dealRisk(mk({ probability: 50, closeDate: "2026-09-01" }), NOW),
).toBe("medium");
expect(
dealRisk(mk({ probability: 80, closeDate: "2026-06-10" }), NOW),
).toBe("medium");
});
it("is low for strong, far-out open deals", () => {
expect(
dealRisk(mk({ probability: 80, closeDate: "2026-12-01" }), NOW),
).toBe("low");
});
});
describe("computeKpis", () => {
const crm: CrmState = {
accounts: [],
contacts: [],
activities: [],
products: [],
salespeople: [],
reports: [],
quotes: [],
deals: [
mk({
id: "1",
amount: 40000,
probability: 50,
stage: "Qualified",
closeDate: "2026-12-01",
}),
mk({
id: "2",
amount: 20000,
probability: 20,
stage: "Lead",
closeDate: "2026-12-01",
}),
mk({ id: "3", amount: 30000, stage: "Closed Won" }),
mk({ id: "4", amount: 10000, stage: "Closed Lost" }),
],
};
it("sums open pipeline (excludes closed)", () => {
expect(computeKpis(crm, NOW).openPipeline).toBe(60000);
});
it("computes weighted forecast over open deals", () => {
expect(computeKpis(crm, NOW).weightedForecast).toBe(24000);
});
it("computes win rate from closed deals", () => {
expect(computeKpis(crm, NOW).winRate).toBeCloseTo(0.5);
});
it("counts at-risk open deals", () => {
expect(computeKpis(crm, NOW).atRisk).toBe(2);
});
it("win rate is null with no closed deals", () => {
expect(
computeKpis({ ...crm, deals: [crm.deals[0]] }, NOW).winRate,
).toBeNull();
});
});
function baseState(): CrmState {
return {
deals: [
{
id: "d1",
accountId: "a1",
name: "D1",
amount: 1000,
stage: "Proposal",
probability: 60,
closeDate: "2026-07-01",
ownerName: "You",
ownerId: "s1",
lineItems: [],
},
{
id: "d2",
accountId: "a1",
name: "D2",
amount: 2000,
stage: "Lead",
probability: 20,
closeDate: "2026-07-01",
ownerName: "You",
ownerId: "s1",
lineItems: [],
},
],
accounts: [],
contacts: [],
activities: [],
products: [],
salespeople: [],
reports: [],
quotes: [],
};
}
describe("applyStageOverlay", () => {
it("returns the same object when overlay is empty", () => {
const s = baseState();
expect(applyStageOverlay(s, {})).toBe(s);
});
it("overrides a deal's stage", () => {
const out = applyStageOverlay(baseState(), { d1: "Negotiation" });
expect(out.deals.find((d) => d.id === "d1")!.stage).toBe("Negotiation");
expect(out.deals.find((d) => d.id === "d2")!.stage).toBe("Lead");
});
it("sets probability 100/0 for Closed Won/Lost", () => {
expect(
applyStageOverlay(baseState(), { d1: "Closed Won" }).deals[0].probability,
).toBe(100);
expect(
applyStageOverlay(baseState(), { d1: "Closed Lost" }).deals[0]
.probability,
).toBe(0);
});
});
describe("pruneOverlay", () => {
it("drops entries the base already reflects", () => {
expect(pruneOverlay(baseState(), { d1: "Proposal" })).toEqual({});
});
it("keeps entries not yet reflected", () => {
expect(pruneOverlay(baseState(), { d1: "Negotiation" })).toEqual({
d1: "Negotiation",
});
});
it("drops entries for deals that no longer exist", () => {
expect(pruneOverlay(baseState(), { gone: "Lead" })).toEqual({});
});
});
describe("relativeTime", () => {
const now = new Date("2026-06-04T12:00:00.000Z").getTime();
it("formats minutes", () => {
expect(relativeTime("2026-06-04T11:30:00.000Z", now)).toBe("30m ago");
});
it("formats hours", () => {
expect(relativeTime("2026-06-04T09:00:00.000Z", now)).toBe("3h ago");
});
it("formats days", () => {
expect(relativeTime("2026-06-01T12:00:00.000Z", now)).toBe("3d ago");
});
});
// --- analytics fixtures ----------------------------------------------------
const PRODUCTS: Product[] = [
{
id: "p1",
name: "Laptop",
category: "Laptop",
sku: "L",
unitPrice: 1000,
photoUrl: "",
specs: "",
blurb: "",
},
{
id: "p2",
name: "Server",
category: "Server",
sku: "S",
unitPrice: 5000,
photoUrl: "",
specs: "",
blurb: "",
},
{
id: "p3",
name: "Mouse",
category: "Accessory",
sku: "M",
unitPrice: 50,
photoUrl: "",
specs: "",
blurb: "",
},
];
const REPS: Salesperson[] = [
{
id: "s1",
name: "Ann",
email: "a@x.com",
avatarUrl: "",
role: "AE",
region: "West",
quota: 100000,
},
{
id: "s2",
name: "Bob",
email: "b@x.com",
avatarUrl: "",
role: "AE",
region: "East",
quota: 50000,
},
{
id: "s3",
name: "Cleo",
email: "c@x.com",
avatarUrl: "",
role: "Manager",
region: "Central",
quota: 0,
},
];
// NOW = 2026-06-03 → 8-month window keys: 2025-11 .. 2026-06
function analyticsState(): CrmState {
return {
accounts: [],
contacts: [],
activities: [],
reports: [],
quotes: [],
products: PRODUCTS,
salespeople: REPS,
deals: [
// s1: two won (Apr + Jun 2026) = 70000 bookings, one open (lineItems 2×Laptop + 1×Server = 7000)
mk({
id: "w1",
ownerId: "s1",
amount: 30000,
stage: "Closed Won",
closeDate: "2026-04-15",
}),
mk({
id: "w2",
ownerId: "s1",
amount: 40000,
stage: "Closed Won",
closeDate: "2026-06-02",
}),
mk({
id: "o1",
ownerId: "s1",
amount: 7000,
stage: "Proposal",
closeDate: "2026-09-01",
lineItems: [
{ productId: "p1", qty: 2, unitPrice: 1000 },
{ productId: "p2", qty: 1, unitPrice: 5000 },
],
}),
// s2: one won (Jan 2026) = 12000, one lost, one open (1×Server = 5000)
mk({
id: "w3",
ownerId: "s2",
amount: 12000,
stage: "Closed Won",
closeDate: "2026-01-20",
}),
mk({
id: "l1",
ownerId: "s2",
amount: 9000,
stage: "Closed Lost",
closeDate: "2026-02-01",
}),
mk({
id: "o2",
ownerId: "s2",
amount: 5000,
stage: "Qualified",
closeDate: "2026-08-01",
lineItems: [{ productId: "p2", qty: 1, unitPrice: 5000 }],
}),
// out-of-window won (should NOT appear in salesOverTime/trend): Jan 2025
mk({
id: "old",
ownerId: "s1",
amount: 99000,
stage: "Closed Won",
closeDate: "2025-01-10",
}),
],
};
}
describe("salesOverTime", () => {
it("returns 8 ascending months including zero months", () => {
const series = salesOverTime(analyticsState(), NOW);
expect(series.map((p) => p.month)).toEqual([
"2025-11",
"2025-12",
"2026-01",
"2026-02",
"2026-03",
"2026-04",
"2026-05",
"2026-06",
]);
expect(series.map((p) => p.label)).toEqual([
"Nov",
"Dec",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
]);
});
it("sums Closed-Won amount by close month, ignoring open/lost and out-of-window", () => {
const series = salesOverTime(analyticsState(), NOW);
const byMonth = Object.fromEntries(
series.map((p) => [p.month, p.bookings]),
);
expect(byMonth["2026-01"]).toBe(12000); // s2 w3
expect(byMonth["2026-04"]).toBe(30000); // s1 w1
expect(byMonth["2026-06"]).toBe(40000); // s1 w2
expect(byMonth["2026-02"]).toBe(0); // lost deal excluded
expect(byMonth["2025-12"]).toBe(0); // empty month
});
});
describe("revenueByCategory", () => {
it("sums OPEN deals' line items by category and skips zero", () => {
const out = revenueByCategory(analyticsState());
const byCat = Object.fromEntries(out.map((c) => [c.category, c.value]));
// open line items: o1 = 2×1000 Laptop + 1×5000 Server; o2 = 1×5000 Server
expect(byCat["Laptop"]).toBe(2000);
expect(byCat["Server"]).toBe(10000);
expect(byCat["Accessory"]).toBeUndefined(); // no open accessory line items
expect(out.every((c) => c.value > 0)).toBe(true);
});
});
describe("teamLeaderboard", () => {
it("aggregates per rep and sorts by bookings desc", () => {
const rows = teamLeaderboard(analyticsState(), NOW);
expect(rows.map((r) => r.salespersonId)).toEqual(["s1", "s2", "s3"]);
const s1 = rows.find((r) => r.salespersonId === "s1")!;
expect(s1.bookings).toBe(30000 + 40000 + 99000); // all s1 won (incl out-of-window; leaderboard is all-time)
expect(s1.openPipeline).toBe(7000);
expect(s1.attainment).toBeCloseTo((30000 + 40000 + 99000) / 100000);
expect(s1.dealCount).toBe(4);
const s2 = rows.find((r) => r.salespersonId === "s2")!;
expect(s2.bookings).toBe(12000);
expect(s2.openPipeline).toBe(5000);
});
it("uses attainment 0 when quota is 0", () => {
const s3 = teamLeaderboard(analyticsState(), NOW).find(
(r) => r.salespersonId === "s3",
)!;
expect(s3.attainment).toBe(0);
expect(s3.quota).toBe(0);
expect(s3.dealCount).toBe(0);
});
});
describe("repStats", () => {
it("returns null for an unknown rep", () => {
expect(repStats(analyticsState(), "nope", NOW)).toBeNull();
});
it("computes bookings, pipeline, attainment, winRate, dealCount and an 8-point trend", () => {
const r = repStats(analyticsState(), "s1", NOW)!;
expect(r.rep.name).toBe("Ann");
expect(r.bookings).toBe(30000 + 40000 + 99000);
expect(r.openPipeline).toBe(7000);
expect(r.attainment).toBeCloseTo((30000 + 40000 + 99000) / 100000);
expect(r.winRate).toBe(1); // 2 won, 0 lost (out-of-window won counts; no lost for s1)
expect(r.dealCount).toBe(4);
expect(r.trend).toHaveLength(8);
// trend mirrors salesOverTime months: Apr=30000, Jun=40000, rest 0 (out-of-window excluded)
expect(r.trend).toEqual([0, 0, 0, 0, 0, 30000, 0, 40000]);
expect(r.deals.map((d) => d.id).sort()).toEqual(["o1", "old", "w1", "w2"]);
});
it("computes winRate from won and lost for a rep with both", () => {
const r = repStats(analyticsState(), "s2", NOW)!;
expect(r.winRate).toBeCloseTo(0.5); // 1 won, 1 lost
expect(r.bookings).toBe(12000);
});
});
describe("teamStats", () => {
it("aggregates whole-team bookings, forecast, win rate, leaderboard, and category mix", () => {
const ts = teamStats(analyticsState(), NOW);
expect(ts.totalBookings).toBe(30000 + 40000 + 12000 + 99000); // all Closed-Won, all-time = 181000
expect(ts.weightedForecast).toBe(6000); // open: o1 7000×0.5 + o2 5000×0.5
expect(ts.winRate).toBeCloseTo(0.8); // 4 won / (4 won + 1 lost)
expect(ts.leaderboard.map((r) => r.salespersonId)).toEqual([
"s1",
"s2",
"s3",
]);
expect(ts.leaderboard[0].bookings).toBe(30000 + 40000 + 99000); // s1 = 169000
const byCat = Object.fromEntries(
ts.byCategory.map((c) => [c.category, c.value]),
);
expect(byCat["Laptop"]).toBe(2000);
expect(byCat["Server"]).toBe(10000);
});
it("win rate is null when there are no closed deals", () => {
const open = teamStats(
{ ...analyticsState(), deals: [mk({ stage: "Qualified" })] },
NOW,
);
expect(open.winRate).toBeNull();
});
});