Skip to content

Commit a9694b4

Browse files
calculate allocation factors
1 parent 7512a20 commit a9694b4

11 files changed

Lines changed: 349 additions & 2 deletions

File tree

internal/datastore/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from "./mi-repository";
33
export * from "./letter-repository";
44
export * from "./supplier-repository";
55
export * from "./supplier-config-repository";
6+
export * from "./supplier-quotas-repository";
67
export { default as LetterQueueRepository } from "./letter-queue-repository";
78
export { default as DBHealthcheck } from "./healthcheck";
89
export { default as LetterAlreadyExistsError } from "./errors/letter-already-exists-error";
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/__tests__/deps.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Deps } from "lambdas/supplier-allocator/src/config/deps";
33
describe("createDependenciesContainer", () => {
44
const env = {
55
SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable",
6+
SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable",
67
VARIANT_MAP: {
78
lv1: {
89
supplierId: "supplier1",

lambdas/supplier-allocator/src/config/__tests__/env.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("lambdaEnv", () => {
1616

1717
it("should load all environment variables successfully", () => {
1818
process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable";
19+
process.env.SUPPLIER_QUOTAS_TABLE_NAME = "SupplierQuotasTable";
1920
process.env.VARIANT_MAP = `{
2021
"lv1": {
2122
"supplierId": "supplier1",

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

Lines changed: 15 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;
@@ -30,11 +32,24 @@ function createSupplierConfigRepository(
3032
return new SupplierConfigRepository(createDocumentClient(), config);
3133
}
3234

35+
function createSupplierQuotasRepository(
36+
log: Logger,
37+
// eslint-disable-next-line @typescript-eslint/no-shadow
38+
envVars: EnvVars,
39+
): SupplierQuotasRepository {
40+
const config = {
41+
supplierQuotasTableName: envVars.SUPPLIER_QUOTAS_TABLE_NAME,
42+
};
43+
44+
return new SupplierQuotasRepository(createDocumentClient(), config);
45+
}
46+
3347
export function createDependenciesContainer(): Deps {
3448
const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL });
3549

3650
return {
3751
supplierConfigRepo: createSupplierConfigRepository(log, envVars),
52+
supplierQuotasRepo: createSupplierQuotasRepository(log, envVars),
3853
logger: log,
3954
env: envVars,
4055
sqsClient: new SQSClient({}),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type LetterVariant = z.infer<typeof LetterVariantSchema>;
1313

1414
const EnvVarsSchema = z.object({
1515
SUPPLIER_CONFIG_TABLE_NAME: z.string(),
16+
SUPPLIER_QUOTAS_TABLE_NAME: z.string(),
1617
PINO_LOG_LEVEL: z.coerce.string().optional(),
1718
VARIANT_MAP: z.string().transform((str, _) => {
1819
const parsed = JSON.parse(str);

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import {
77
$LetterStatusChangeEvent,
88
LetterStatusChangeEvent,
99
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
10-
import { SupplierConfigRepository } from "@internal/datastore";
10+
import {
11+
SupplierConfigRepository,
12+
SupplierQuotasRepository,
13+
} from "@internal/datastore";
1114
import createSupplierAllocatorHandler from "../allocate-handler";
1215
import * as supplierConfig from "../../services/supplier-config";
16+
import * as supplierQuotas from "../../services/supplier-quotas";
1317

1418
import { Deps } from "../../config/deps";
1519
import { EnvVars } from "../../config/env";
@@ -21,6 +25,7 @@ const renderingSchemaVersion: string =
2125
];
2226

2327
jest.mock("../../services/supplier-config");
28+
jest.mock("../../services/supplier-quotas");
2429

2530
function createSQSEvent(records: SQSRecord[]): SQSEvent {
2631
return {
@@ -169,12 +174,19 @@ function setupDefaultMocks() {
169174
colour: false,
170175
duplex: false,
171176
});
177+
(
178+
supplierQuotas.calculateSupplierAllocatedFactor as jest.Mock
179+
).mockResolvedValue({
180+
supplierId: "supplier-1",
181+
factor: 0.5,
182+
});
172183
}
173184

174185
describe("createSupplierAllocatorHandler", () => {
175186
let mockSqsClient: jest.Mocked<SQSClient>;
176187
let mockedDeps: jest.Mocked<Deps>;
177188
let mockedSupplierConfigRepo: jest.Mocked<SupplierConfigRepository>;
189+
let mockedSupplierQuotasRepo: jest.Mocked<SupplierQuotasRepository>;
178190
beforeEach(() => {
179191
mockSqsClient = {
180192
send: jest.fn(),
@@ -191,10 +203,22 @@ describe("createSupplierAllocatorHandler", () => {
191203
getPackSpecification: jest.fn(),
192204
} as jest.Mocked<SupplierConfigRepository>;
193205

206+
mockedSupplierQuotasRepo = {
207+
ddbClient: {} as any,
208+
config: {} as any,
209+
getOverallAllocation: jest.fn(),
210+
putOverallAllocation: jest.fn(),
211+
updateOverallAllocation: jest.fn(),
212+
getDailyAllocation: jest.fn(),
213+
putDailyAllocation: jest.fn(),
214+
updateDailyAllocation: jest.fn(),
215+
} as jest.Mocked<SupplierQuotasRepository>;
216+
194217
mockedDeps = {
195218
logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger,
196219
env: {
197220
SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable",
221+
SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable",
198222
VARIANT_MAP: {
199223
lv1: {
200224
supplierId: "supplier1",
@@ -206,6 +230,7 @@ describe("createSupplierAllocatorHandler", () => {
206230
} as EnvVars,
207231
sqsClient: mockSqsClient,
208232
supplierConfigRepo: mockedSupplierConfigRepo,
233+
supplierQuotasRepo: mockedSupplierQuotasRepo,
209234
} as jest.Mocked<Deps>;
210235
jest.clearAllMocks();
211236
});
@@ -434,6 +459,39 @@ describe("createSupplierAllocatorHandler", () => {
434459
);
435460
});
436461

462+
test("returns batch failure when variant mapping is missing for multiple events", async () => {
463+
const preparedEvent1 = createPreparedV2Event();
464+
preparedEvent1.data.letterVariantId = "missing-variant1";
465+
const preparedEvent2 = createPreparedV2Event();
466+
preparedEvent2.data.letterVariantId = "missing-variant2";
467+
468+
const evt: SQSEvent = createSQSEvent([
469+
createSqsRecord("msg1", JSON.stringify(preparedEvent1)),
470+
createSqsRecord("msg2", JSON.stringify(preparedEvent2)),
471+
]);
472+
473+
process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
474+
475+
// Override variant map to be empty for this test
476+
mockedDeps.env.VARIANT_MAP = {} as any;
477+
478+
const handler = createSupplierAllocatorHandler(mockedDeps);
479+
const result = await handler(evt, {} as any, {} as any);
480+
if (!result) throw new Error("expected BatchResponse, got void");
481+
482+
expect(result.batchItemFailures).toHaveLength(2);
483+
expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1");
484+
expect(result.batchItemFailures[1].itemIdentifier).toBe("msg2");
485+
expect(
486+
(mockedDeps.logger.error as jest.Mock).mock.calls.length,
487+
).toBeGreaterThan(0);
488+
expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual(
489+
expect.objectContaining({
490+
description: "No supplier mapping found for variant",
491+
}),
492+
);
493+
});
494+
437495
test("handles SQS send errors and returns batch failure", async () => {
438496
const preparedEvent = createPreparedV2Event();
439497

0 commit comments

Comments
 (0)