Skip to content

Commit ccd89fa

Browse files
calculate allocation factors
1 parent e799dec commit ccd89fa

7 files changed

Lines changed: 316 additions & 1 deletion

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
DynamoDBDocumentClient,
3+
GetCommand,
4+
PutCommand,
5+
UpdateCommand,
6+
} from "@aws-sdk/lib-dynamodb";
7+
import {
8+
$DailyAllocation,
9+
$OverallAllocation,
10+
DailyAllocation,
11+
OverallAllocation,
12+
} from "./types";
13+
14+
export type SupplierQuotasRepositoryConfig = {
15+
supplierQuotasTableName: string;
16+
};
17+
18+
function ItemForRecord(
19+
entity: string,
20+
id: string,
21+
record: Record<string, any>,
22+
): Record<string, any> {
23+
return {
24+
pk: `ENTITY#${entity}`,
25+
sk: `ID#${id}`,
26+
...record,
27+
};
28+
}
29+
30+
export class SupplierQuotasRepository {
31+
constructor(
32+
readonly ddbClient: DynamoDBDocumentClient,
33+
readonly config: SupplierQuotasRepositoryConfig,
34+
) {}
35+
36+
async getOverallAllocation(groupId: string): Promise<OverallAllocation> {
37+
const result = await this.ddbClient.send(
38+
new GetCommand({
39+
TableName: this.config.supplierQuotasTableName,
40+
Key: { pk: "ENTITY#overall_allocation", sk: `ID#${groupId}` },
41+
}),
42+
);
43+
if (!result.Item) {
44+
throw new Error(
45+
`No overall allocation found for volume group id ${groupId}`,
46+
);
47+
}
48+
return $OverallAllocation.parse(result.Item);
49+
}
50+
51+
async putOverallAllocation(allocation: OverallAllocation): Promise<void> {
52+
await this.ddbClient.send(
53+
new PutCommand({
54+
TableName: this.config.supplierQuotasTableName,
55+
Item: ItemForRecord(
56+
"overall_allocation",
57+
allocation.id,
58+
$OverallAllocation.parse(allocation),
59+
),
60+
}),
61+
);
62+
}
63+
64+
// Update the overallAllocation table updating the allocations array for a given volume group
65+
// or adding the value if the supplier is not present //
66+
async updateOverallAllocation(
67+
groupId: string,
68+
supplierId: string,
69+
newAllocation: number,
70+
): Promise<void> {
71+
const overallAllocation = await this.getOverallAllocation(groupId);
72+
const currentAllocation = overallAllocation.allocations[supplierId] ?? 0;
73+
const updatedAllocation = currentAllocation + newAllocation;
74+
75+
await this.ddbClient.send(
76+
new UpdateCommand({
77+
TableName: this.config.supplierQuotasTableName,
78+
Key: { pk: "ENTITY#overall_allocation", sk: `ID#${groupId}` },
79+
UpdateExpression:
80+
"SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt",
81+
ExpressionAttributeNames: {
82+
"#supplierId": supplierId,
83+
},
84+
ExpressionAttributeValues: {
85+
":updatedAllocation": updatedAllocation,
86+
":updatedAt": new Date().toISOString(),
87+
},
88+
}),
89+
);
90+
}
91+
92+
async getDailyAllocation(
93+
groupId: string,
94+
date: string,
95+
): Promise<DailyAllocation> {
96+
const result = await this.ddbClient.send(
97+
new GetCommand({
98+
TableName: this.config.supplierQuotasTableName,
99+
Key: {
100+
pk: "ENTITY#daily_allocation",
101+
sk: `ID#${groupId}#DATE#${date}`,
102+
},
103+
}),
104+
);
105+
if (!result.Item) {
106+
throw new Error(
107+
`No daily allocation found for volume group id ${groupId} and date ${date}`,
108+
);
109+
}
110+
return $DailyAllocation.parse(result.Item);
111+
}
112+
113+
async putDailyAllocation(allocation: DailyAllocation): Promise<void> {
114+
await this.ddbClient.send(
115+
new PutCommand({
116+
TableName: this.config.supplierQuotasTableName,
117+
Item: ItemForRecord(
118+
"daily_allocation",
119+
`${allocation.volumeGroup}#DATE#${allocation.date}`,
120+
$DailyAllocation.parse(allocation),
121+
),
122+
}),
123+
);
124+
}
125+
126+
async updateDailyAllocation(
127+
groupId: string,
128+
date: string,
129+
supplierId: string,
130+
newAllocation: number,
131+
): Promise<void> {
132+
const dailyAllocation = await this.getDailyAllocation(groupId, date);
133+
const currentAllocation = dailyAllocation.allocations[supplierId] ?? 0;
134+
const updatedAllocation = currentAllocation + newAllocation;
135+
136+
await this.ddbClient.send(
137+
new UpdateCommand({
138+
TableName: this.config.supplierQuotasTableName,
139+
Key: {
140+
pk: "ENTITY#daily_allocation",
141+
sk: `ID#${groupId}#DATE#${date}`,
142+
},
143+
UpdateExpression:
144+
"SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt",
145+
ExpressionAttributeNames: {
146+
"#supplierId": supplierId,
147+
},
148+
ExpressionAttributeValues: {
149+
":updatedAllocation": updatedAllocation,
150+
":updatedAt": new Date().toISOString(),
151+
},
152+
}),
153+
);
154+
}
155+
}

internal/datastore/src/types.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { z } from "zod";
22
import { idRef } from "@internal/helpers";
3+
import {
4+
$Supplier,
5+
$VolumeGroup,
6+
} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
37

48
export const SupplierStatus = z.enum(["ENABLED", "DISABLED"]);
59

@@ -120,3 +124,38 @@ export const MISchema = MISchemaBase.extend({
120124

121125
export type MI = z.infer<typeof MISchema>;
122126
export type MIBase = z.infer<typeof MISchemaBase>;
127+
128+
export const $OverallAllocation = z
129+
.object({
130+
id: z.string(),
131+
volumeGroup: idRef($VolumeGroup, "id"),
132+
allocations: z.record(
133+
idRef($Supplier, "id"),
134+
z.number().int().nonnegative(),
135+
),
136+
})
137+
.meta({
138+
title: "OverallAllocation",
139+
description:
140+
"The overall allocation for a volume group, including all suppliers",
141+
});
142+
143+
export type OverallAllocation = z.infer<typeof $OverallAllocation>;
144+
145+
export const $DailyAllocation = z
146+
.object({
147+
id: z.string(),
148+
date: z.ZodISODate,
149+
volumeGroup: idRef($VolumeGroup, "id"),
150+
allocations: z.record(
151+
idRef($Supplier, "id"),
152+
z.number().int().nonnegative(),
153+
),
154+
})
155+
.meta({
156+
title: "DailyAllocation",
157+
description:
158+
"The daily allocation for a volume group, including all suppliers",
159+
});
160+
161+
export type DailyAllocation = z.infer<typeof $DailyAllocation>;

lambdas/supplier-allocator/jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const baseJestConfig = {
1414
clearMocks: true,
1515

1616
// Indicates whether the coverage information should be collected while executing the test
17-
collectCoverage: true,
17+
collectCoverage: false,
1818

1919
// The directory where Jest should output its coverage files
2020
coverageDirectory: "./.reports/unit/coverage",

lambdas/supplier-allocator/src/config/deps.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { SQSClient } from "@aws-sdk/client-sqs";
44
import { Logger } from "pino";
55
import { createLogger } from "@internal/helpers";
66
import { SupplierConfigRepository } from "@internal/datastore";
7+
import { SupplierQuotasRepository } from "@internal/datastore";
78
import { EnvVars, envVars } from "./env";
89

910
export type Deps = {
1011
supplierConfigRepo: SupplierConfigRepository;
12+
supplierQuotasRepo: SupplierQuotasRepository;
1113
logger: Logger;
1214
env: EnvVars;
1315
sqsClient: SQSClient;

lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { SupplierConfigRepository } from "@internal/datastore";
1111
import createSupplierAllocatorHandler from "../allocate-handler";
1212
import * as supplierConfig from "../../services/supplier-config";
13+
import * as supplierQuotas from "../../services/supplier-quotas";
1314

1415
import { Deps } from "../../config/deps";
1516
import { EnvVars } from "../../config/env";
@@ -21,6 +22,7 @@ const renderingSchemaVersion: string =
2122
];
2223

2324
jest.mock("../../services/supplier-config");
25+
jest.mock("../../services/supplier-quotas");
2426

2527
function createSQSEvent(records: SQSRecord[]): SQSEvent {
2628
return {
@@ -169,12 +171,19 @@ function setupDefaultMocks() {
169171
colour: false,
170172
duplex: false,
171173
});
174+
(
175+
supplierQuotas.calculateSupplierAllocatedFactor as jest.Mock
176+
).mockResolvedValue({
177+
supplierId: "supplier-1",
178+
factor: 0.5,
179+
});
172180
}
173181

174182
describe("createSupplierAllocatorHandler", () => {
175183
let mockSqsClient: jest.Mocked<SQSClient>;
176184
let mockedDeps: jest.Mocked<Deps>;
177185
let mockedSupplierConfigRepo: jest.Mocked<SupplierConfigRepository>;
186+
let mockedSupplierQuotasRepo: jest.Mocked<supplierQuotas.SupplierQuotasRepository>;
178187
beforeEach(() => {
179188
mockSqsClient = {
180189
send: jest.fn(),
@@ -206,6 +215,7 @@ describe("createSupplierAllocatorHandler", () => {
206215
} as EnvVars,
207216
sqsClient: mockSqsClient,
208217
supplierConfigRepo: mockedSupplierConfigRepo,
218+
supplierQuotasRepo: mockedSupplierQuotasRepo,
209219
} as jest.Mocked<Deps>;
210220
jest.clearAllMocks();
211221
});
@@ -434,6 +444,39 @@ describe("createSupplierAllocatorHandler", () => {
434444
);
435445
});
436446

447+
test("returns batch failure when variant mapping is missing for multiple events", async () => {
448+
const preparedEvent1 = createPreparedV2Event();
449+
preparedEvent1.data.letterVariantId = "missing-variant1";
450+
const preparedEvent2 = createPreparedV2Event();
451+
preparedEvent2.data.letterVariantId = "missing-variant2";
452+
453+
const evt: SQSEvent = createSQSEvent([
454+
createSqsRecord("msg1", JSON.stringify(preparedEvent1)),
455+
createSqsRecord("msg2", JSON.stringify(preparedEvent2)),
456+
]);
457+
458+
process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
459+
460+
// Override variant map to be empty for this test
461+
mockedDeps.env.VARIANT_MAP = {} as any;
462+
463+
const handler = createSupplierAllocatorHandler(mockedDeps);
464+
const result = await handler(evt, {} as any, {} as any);
465+
if (!result) throw new Error("expected BatchResponse, got void");
466+
467+
expect(result.batchItemFailures).toHaveLength(2);
468+
expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1");
469+
expect(result.batchItemFailures[1].itemIdentifier).toBe("msg2");
470+
expect(
471+
(mockedDeps.logger.error as jest.Mock).mock.calls.length,
472+
).toBeGreaterThan(0);
473+
expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual(
474+
expect.objectContaining({
475+
description: "No supplier mapping found for variant",
476+
}),
477+
);
478+
});
479+
437480
test("handles SQS send errors and returns batch failure", async () => {
438481
const preparedEvent = createPreparedV2Event();
439482

lambdas/supplier-allocator/src/handler/allocate-handler.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getVariantDetails,
2424
getVolumeGroupDetails,
2525
} from "../services/supplier-config";
26+
import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas";
2627
import { Deps } from "../config/deps";
2728

2829
type SupplierSpec = {
@@ -116,6 +117,28 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) {
116117
deps,
117118
);
118119

120+
let supplierAllocationsForPack: SupplierAllocation[] = [];
121+
let supplierFactors: { supplierId: string; factor: number }[] = [];
122+
123+
if (suppliersForPack && suppliersForPack.length > 0) {
124+
supplierAllocationsForPack = supplierAllocations.filter((alloc) =>
125+
suppliersForPack.some((supplier) => supplier.id === alloc.supplier),
126+
);
127+
128+
console.log("Supplier allocations for pack", {
129+
supplierAllocationsForPack,
130+
});
131+
132+
supplierFactors = await calculateSupplierAllocatedFactor(
133+
supplierAllocationsForPack,
134+
deps,
135+
);
136+
137+
console.log("Supplier factors calculated for allocation", {
138+
supplierFactors,
139+
});
140+
}
141+
119142
deps.logger.info({
120143
description: "Fetched supplier details for supplier allocations",
121144
variantId: letterEvent.data.letterVariantId,
@@ -127,6 +150,8 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) {
127150
preferredSupplierPacks,
128151
preferredPack,
129152
suppliersForPack,
153+
supplierAllocationsForPack,
154+
supplierFactors,
130155
});
131156

132157
return allocatedSuppliers;
@@ -184,6 +209,8 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler {
184209
const perAllocationSuccess: AllocationMetrics = new Map();
185210
const perAllocationFailure: AllocationMetrics = new Map();
186211

212+
// Initialise the supplier quotas.
213+
187214
const tasks = event.Records.map(async (record) => {
188215
let supplier = "unknown";
189216
let priority = "unknown";

0 commit comments

Comments
 (0)