From bc68a3bdcbac90e041baa2d7d3b2b4bf890d2d10 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 19 Mar 2026 15:34:59 +0000 Subject: [PATCH 01/25] CCM-13372 - Select Preferred Pack --- .../api/ddb_table_supplier_configuration.tf | 12 + .../api/module_lambda_supplier_allocator.tf | 3 +- internal/datastore/src/__test__/db.ts | 11 + .../supplier-config-repository.test.ts | 82 +++++++ .../src/supplier-config-repository.ts | 44 ++++ .../__tests__/allocate-handler.test.ts | 13 +- .../src/handler/allocate-handler.ts | 38 +++- .../__tests__/supplier-config.test.ts | 215 +++++++++++++++--- .../src/services/supplier-config.ts | 81 ++++++- 9 files changed, 451 insertions(+), 48 deletions(-) diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf index f751e2ef4..e72f9543e 100644 --- a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf +++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf @@ -25,6 +25,11 @@ resource "aws_dynamodb_table" "supplier-configuration" { type = "S" } + attribute { + name = "packSpecificationId" + type = "S" + } + global_secondary_index { name = "volumeGroup-index" hash_key = "pk" @@ -32,6 +37,13 @@ resource "aws_dynamodb_table" "supplier-configuration" { projection_type = "ALL" } + global_secondary_index { + name = "packSpecificationId-index" + hash_key = "PK" + range_key = "packSpecificationId" + projection_type = "ALL" + } + point_in_time_recovery { enabled = true } diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index b568307c9..c2013fb6f 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -94,8 +94,7 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { resources = [ aws_dynamodb_table.supplier-configuration.arn, - "${aws_dynamodb_table.supplier-configuration.arn}/index/volumeGroup-index" - + "${aws_dynamodb_table.supplier-configuration.arn}/index/*" ] } } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 9d0bf0e1e..6f50af34d 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -165,11 +165,22 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ ProjectionType: "ALL", }, }, + { + IndexName: "packSpecificationId-index", + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "packSpecificationId", KeyType: "RANGE" }, // Sort key for GSI + ], + Projection: { + ProjectionType: "ALL", + }, + }, ], AttributeDefinitions: [ { AttributeName: "pk", AttributeType: "S" }, { AttributeName: "sk", AttributeType: "S" }, { AttributeName: "volumeGroup", AttributeType: "S" }, + { AttributeName: "packSpecificationId", AttributeType: "S" }, ], }); diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index ddd44fd4d..b2beea82e 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -263,4 +263,86 @@ describe("SupplierConfigRepository", () => { `Supplier with id ${supplierId} not found`, ); }); + + test("getSupplierPacksForPackSpecification returns correct supplier packs", async () => { + const packSpecId = "pack-spec-123"; + const supplierId = "supplier-123"; + const supplierPackId = "supplier-pack-123"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: { + PK: "SUPPLIER_PACK", + SK: supplierPackId, + id: supplierPackId, + packSpecificationId: packSpecId, + supplierId, + status: "PROD", + approval: "APPROVED", + }, + }), + ); + + const result = + await repository.getSupplierPacksForPackSpecification(packSpecId); + expect(result).toEqual([ + { + approval: "APPROVED", + id: supplierPackId, + packSpecificationId: packSpecId, + supplierId, + status: "PROD", + }, + ]); + }); + + test("getSupplierPacksForPackSpecification returns empty array for non-existent pack specification", async () => { + const packSpecId = "non-existent-pack-spec"; + const result = + await repository.getSupplierPacksForPackSpecification(packSpecId); + expect(result).toEqual([]); + }); + + test("getPackSpecification returns correct pack specification details", async () => { + const packSpecId = "pack-spec-123"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: { + PK: "PACK_SPECIFICATION", + SK: packSpecId, + id: packSpecId, + name: `Pack Specification ${packSpecId}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + billingId: `billing-${packSpecId}`, + postage: { id: "postageId", size: "STANDARD" }, + status: "PROD", + }, + }), + ); + + const result = await repository.getPackSpecification(packSpecId); + expect(result).toEqual({ + billingId: `billing-${packSpecId}`, + createdAt: expect.any(String), + id: packSpecId, + name: `Pack Specification ${packSpecId}`, + postage: { id: "postageId", size: "STANDARD" }, + updatedAt: expect.any(String), + version: 1, + status: "PROD", + }); + }); + + test("getPackSpecification throws error for non-existent pack specification", async () => { + const packSpecId = "non-existent-pack-spec"; + + await expect(repository.getPackSpecification(packSpecId)).rejects.toThrow( + `No pack specification found for id ${packSpecId}`, + ); + }); }); diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index 4eeeddb10..d7a644b52 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -5,12 +5,16 @@ import { } from "@aws-sdk/lib-dynamodb"; import { $LetterVariant, + $PackSpecification, $Supplier, $SupplierAllocation, + $SupplierPack, $VolumeGroup, LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; @@ -97,4 +101,44 @@ export class SupplierConfigRepository { } return suppliers; } + + async getSupplierPacksForPackSpecification( + packSpecId: string, + ): Promise { + const result = await this.ddbClient.send( + new QueryCommand({ + TableName: this.config.supplierConfigTableName, + IndexName: "packSpecificationId-index", + KeyConditionExpression: "#pk = :pk AND #packSpecId = :packSpecId", + FilterExpression: "#status = :status AND #approval = :approval", + ExpressionAttributeNames: { + "#pk": "PK", + "#packSpecId": "packSpecificationId", + "#status": "status", + "#approval": "approval", + }, + ExpressionAttributeValues: { + ":pk": "SUPPLIER_PACK", + ":packSpecId": packSpecId, + ":status": "PROD", + ":approval": "APPROVED", + }, + }), + ); + + return $SupplierPack.array().parse(result.Items); + } + + async getPackSpecification(packSpecId: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierConfigTableName, + Key: { PK: "PACK_SPECIFICATION", SK: packSpecId }, + }), + ); + if (!result.Item) { + throw new Error(`No pack specification found for id ${packSpecId}`); + } + return $PackSpecification.parse(result.Item); + } } diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index eb1a3bfdb..0b7f452f1 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -1,6 +1,6 @@ +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import { SQSEvent, SQSRecord } from "aws-lambda"; import pino from "pino"; -import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { @@ -158,6 +158,17 @@ function setupDefaultMocks() { priority: 1, billingId: "billing-1", }); + (supplierConfig.getPreferredSupplierPacks as jest.Mock).mockResolvedValue([ + { + packSpecificationId: "pack-spec-1", + }, + ]); + (supplierConfig.getPackSpecification as jest.Mock).mockResolvedValue({ + id: "pack-spec-1", + type: "A4", + colour: false, + duplex: false, + }); } describe("createSupplierAllocatorHandler", () => { diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 2288fae12..ebfede749 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -3,8 +3,10 @@ import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; @@ -12,8 +14,11 @@ import z from "zod"; import { Unit } from "aws-embedded-metrics"; import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers"; import { + getPackSpecification, + getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, + getSuppliersWithValidPack, getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; @@ -83,19 +88,44 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { variantDetails.supplierId, ); - const supplierDetails: Supplier[] = await getSupplierDetails( - supplierAllocations, + const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); + + const allocatedSuppliers: Supplier[] = await getSupplierDetails( + supplierIds, deps, ); + + const preferredSupplierPacks: SupplierPack[] = + await getPreferredSupplierPacks( + variantDetails.packSpecificationIds, + allocatedSuppliers, + deps, + ); + + const preferredPack: PackSpecification = await getPackSpecification( + preferredSupplierPacks[0].packSpecificationId, + deps, + ); + + const suppliersForPack: Supplier[] = await getSuppliersWithValidPack( + allocatedSuppliers, + preferredPack.id, + deps, + ); + deps.logger.info({ description: "Fetched supplier details for supplier allocations", variantId: letterEvent.data.letterVariantId, volumeGroupId: volumeGroupDetails.id, supplierAllocationIds: supplierAllocations.map((a) => a.id), - supplierDetails, + allocatedSuppliers, + eligiblePacks: variantDetails.packSpecificationIds, + preferredSupplierPacks, + preferredPack, + suppliersForPack, }); - return supplierDetails; + return allocatedSuppliers; } catch (error) { deps.logger.error({ description: "Error fetching supplier from config", diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index 7941d1f08..f0ba4d4c9 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -1,6 +1,9 @@ import { + getPackSpecification, + getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, + getSuppliersWithValidPack, getVariantDetails, getVolumeGroupDetails, } from "../supplier-config"; @@ -31,7 +34,7 @@ describe("supplier-config service", () => { afterEach(() => jest.resetAllMocks()); describe("getVariantDetails", () => { - it("returns variant details", async () => { + it("returns variant details for valid id", async () => { const variant = { id: "v1", volumeGroupId: "g1" } as any; const deps = makeDeps(); deps.supplierConfigRepo.getLetterVariant = jest @@ -188,10 +191,7 @@ describe("supplier-config service", () => { describe("getSupplierDetails", () => { it("returns supplier details when found", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; + const supplierIds = ["s1", "s2"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "PROD" }, @@ -201,7 +201,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - const result = await getSupplierDetails(allocations, deps); + const result = await getSupplierDetails(supplierIds, deps); expect(result).toEqual(suppliers); expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ @@ -211,23 +211,19 @@ describe("supplier-config service", () => { }); it("throws when no supplier details found", async () => { - const allocations = [{ supplier: "s1", variantId: "v1" }] as any[]; + const supplierIds = ["s1"]; const deps = makeDeps(); deps.supplierConfigRepo.getSuppliersDetails = jest .fn() .mockResolvedValue([]); - await expect(getSupplierDetails(allocations, deps)).rejects.toThrow( + await expect(getSupplierDetails(supplierIds, deps)).rejects.toThrow( /No supplier details found/, ); }); it("extracts supplier ids from allocations and requests details", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s3", variantId: "v2" }, - { supplier: "s5", variantId: "v3" }, - ] as any[]; + const supplierIds = ["s1", "s3", "s5"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s3", name: "Supplier 3", status: "PROD" }, @@ -238,7 +234,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ "s1", @@ -248,11 +244,7 @@ describe("supplier-config service", () => { }); }); it("logs a warning when supplier allocations count differs from supplier details count", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - { supplier: "s3", variantId: "v3" }, - ] as any[]; + const supplierIds = ["s1", "s2", "s3"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "PROD" }, @@ -262,7 +254,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.logger.warn).toHaveBeenCalledWith({ description: "Mismatch between supplier allocations and supplier details", @@ -273,10 +265,7 @@ describe("supplier-config service", () => { }); it("does not log a warning when counts match", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; + const supplierIds = ["s1", "s2"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "PROD" }, @@ -286,16 +275,13 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.logger.warn).not.toHaveBeenCalled(); }); it("throws when no active suppliers found", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; + const supplierIds = ["s1", "s2"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "DRAFT" }, { id: "s2", name: "Supplier 2", status: "DRAFT" }, @@ -305,7 +291,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await expect(getSupplierDetails(allocations, deps)).rejects.toThrow( + await expect(getSupplierDetails(supplierIds, deps)).rejects.toThrow( /No active suppliers found/, ); expect(deps.logger.error).toHaveBeenCalledWith( @@ -316,11 +302,7 @@ describe("supplier-config service", () => { }); it("filters to return only active suppliers with PROD status", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - { supplier: "s3", variantId: "v3" }, - ] as any[]; + const supplierIds = ["s1", "s2", "s3"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "DRAFT" }, @@ -331,9 +313,170 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - const result = await getSupplierDetails(allocations, deps); + const result = await getSupplierDetails(supplierIds, deps); expect(result).toEqual([suppliers[0], suppliers[2]]); expect(result.every((s) => s.status === "PROD")).toBe(true); }); + describe("getPreferredSupplierPacks", () => { + it("returns preferred supplier packs when found", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + { id: "p3", supplierId: "s3", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + const result = await getPreferredSupplierPacks( + ["spec1"], + suppliers, + deps, + ); + + expect(result).toEqual([ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + ]); + }); + + it("throws when no preferred supplier packs found", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue([]); + + await expect( + getPreferredSupplierPacks(["spec1"], suppliers, deps), + ).rejects.toThrow(/No preferred supplier packs found/); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "No preferred supplier packs found for pack specification ids and suppliers", + }), + ); + }); + it("does not error when at least 1 pack specification has a preferred supplier pack", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValueOnce([]) // no packs for spec1 + .mockResolvedValueOnce([ + { id: "p2", supplierId: "s2", packSpecificationId: "spec2" }, + ]); // preferred pack for spec2 + + const result = await getPreferredSupplierPacks( + ["spec1", "spec2"], + suppliers, + deps, + ); + + expect(result).toEqual([ + { id: "p2", supplierId: "s2", packSpecificationId: "spec2" }, + ]); + }); + + it("throws an error when no suppliers match the pack specification", async () => { + const suppliers = [ + { id: "s4", name: "Supplier 1", status: "PROD" }, + { id: "s5", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + { id: "p3", supplierId: "s3", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + await expect( + getPreferredSupplierPacks(["spec1"], suppliers, deps), + ).rejects.toThrow(/No preferred supplier packs found/); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "No preferred supplier packs found for pack specification ids and suppliers", + }), + ); + }); + }); + + describe("getPackSpecification", () => { + it("returns pack specification when found", async () => { + const packSpec = { + id: "spec1", + name: "Pack Spec 1", + status: "PROD", + } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue(packSpec); + + const result = await getPackSpecification("spec1", deps); + + expect(result).toBe(packSpec); + }); + + it("throws when pack specification is not active based on status", async () => { + const packSpec = { + id: "spec2", + name: "Pack Spec 2", + status: "DRAFT", + } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue(packSpec); + + await expect(getPackSpecification("spec2", deps)).rejects.toThrow( + /not active/, + ); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Pack specification is not active based on status", + packSpecId: "spec2", + status: "DRAFT", + }), + ); + }); + }); + + describe("getSuppliersWithValidPack", () => { + it("returns suppliers that have the valid pack specification", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + const result = await getSuppliersWithValidPack(suppliers, "spec1", deps); + + expect(result).toEqual([ + { id: "s1", name: "Supplier 1", status: "PROD" }, + ]); + }); + }); }); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 9710a68bd..31db9660f 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -1,7 +1,9 @@ import { LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { Deps } from "../config/deps"; @@ -75,11 +77,9 @@ export async function getSupplierAllocationsForVolumeGroup( } export async function getSupplierDetails( - supplierAllocations: SupplierAllocation[], + supplierIds: string[], deps: Deps, ): Promise { - const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); - const supplierDetails: Supplier[] = await deps.supplierConfigRepo.getSuppliersDetails(supplierIds); @@ -93,14 +93,14 @@ export async function getSupplierDetails( ); } // Log a warning if some supplier details are missing compared to allocations - if (supplierAllocations.length !== supplierDetails.length) { + if (supplierIds.length !== supplierDetails.length) { const foundSupplierIds = new Set(supplierDetails.map((s) => s.id)); const missingSupplierIds = supplierIds.filter( (id) => !foundSupplierIds.has(id), ); deps.logger.warn({ description: "Mismatch between supplier allocations and supplier details", - allocationsCount: supplierAllocations.length, + allocationsCount: supplierIds.length, detailsCount: supplierDetails.length, missingSuppliers: missingSupplierIds, }); @@ -117,3 +117,74 @@ export async function getSupplierDetails( } return activeSuppliers; } + +export async function getPreferredSupplierPacks( + packSpecificationIds: string[], + suppliers: Supplier[], + deps: Deps, +): Promise { + for (const packSpecId of packSpecificationIds) { + const supplierPacks = + await deps.supplierConfigRepo.getSupplierPacksForPackSpecification( + packSpecId, + ); + if (supplierPacks.length > 0) { + const preferredPacks = supplierPacks.filter((pack) => + suppliers.some((supplier) => supplier.id === pack.supplierId), + ); + if (preferredPacks.length > 0) { + return preferredPacks; + } + } + } + deps.logger.error({ + description: + "No preferred supplier packs found for pack specification ids and suppliers", + packSpecificationIds, + supplierIds: suppliers.map((s) => s.id), + }); + throw new Error( + `No preferred supplier packs found for pack specification ids ${packSpecificationIds.join(", ")} and suppliers ${suppliers.map((s) => s.id).join(", ")}`, + ); +} + +export async function getPackSpecification( + packSpecId: string, + deps: Deps, +): Promise { + const packSpec = + await deps.supplierConfigRepo.getPackSpecification(packSpecId); + if (packSpec.status !== "PROD") { + deps.logger.error({ + description: "Pack specification is not active based on status", + packSpecId, + status: packSpec.status, + }); + throw new Error(`Pack specification with id ${packSpecId} is not active`); + } + return packSpec; +} + +// This function is used to filter the allocated suppliers based on those that support the supplied pack specification +export async function getSuppliersWithValidPack( + suppliers: Supplier[], + packSpecificationId: string, + deps: Deps, +): Promise { + const suppliersWithValidPack: Supplier[] = []; + const supplierPacks = + await deps.supplierConfigRepo.getSupplierPacksForPackSpecification( + packSpecificationId, + ); + + for (const supplier of suppliers) { + const hasValidPack = supplierPacks.some( + (pack) => pack.supplierId === supplier.id, + ); + if (hasValidPack) { + suppliersWithValidPack.push(supplier); + } + } + + return suppliersWithValidPack; +} From d1a0c138cd1e1134b184dfd165a0db3f3210dfff Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 7 Apr 2026 10:40:45 +0100 Subject: [PATCH 02/25] CCM-13371 - Determine Eligible packs --- lambdas/supplier-allocator/.tool-versions | 1 + .../src/handler/allocate-handler.ts | 16 ++- .../__tests__/supplier-config.test.ts | 126 ++++++++++++++++++ .../src/services/supplier-config.ts | 90 +++++++++++++ 4 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 lambdas/supplier-allocator/.tool-versions diff --git a/lambdas/supplier-allocator/.tool-versions b/lambdas/supplier-allocator/.tool-versions new file mode 100644 index 000000000..a3128f26b --- /dev/null +++ b/lambdas/supplier-allocator/.tool-versions @@ -0,0 +1 @@ +nodejs 22.22.0 diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index ebfede749..7475b9565 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -14,6 +14,7 @@ import z from "zod"; import { Unit } from "aws-embedded-metrics"; import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers"; import { + filterPacksForLetter, getPackSpecification, getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, @@ -95,12 +96,14 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { deps, ); + const eligiblePacks: string[] = await filterPacksForLetter( + letterEvent, + variantDetails.packSpecificationIds, + deps, + ); + const preferredSupplierPacks: SupplierPack[] = - await getPreferredSupplierPacks( - variantDetails.packSpecificationIds, - allocatedSuppliers, - deps, - ); + await getPreferredSupplierPacks(eligiblePacks, allocatedSuppliers, deps); const preferredPack: PackSpecification = await getPackSpecification( preferredSupplierPacks[0].packSpecificationId, @@ -119,7 +122,8 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { volumeGroupId: volumeGroupDetails.id, supplierAllocationIds: supplierAllocations.map((a) => a.id), allocatedSuppliers, - eligiblePacks: variantDetails.packSpecificationIds, + variantPacks: variantDetails.packSpecificationIds, + eligiblePacks, preferredSupplierPacks, preferredPack, suppliersForPack, diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index f0ba4d4c9..ac6b468d5 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -1,4 +1,5 @@ import { + filterPacksForLetter, getPackSpecification, getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, @@ -479,4 +480,129 @@ describe("supplier-config service", () => { ]); }); }); + + describe("filterPacksForLetter", () => { + it("returns eligible packs for letter", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { operator: "LESS_THAN", value: 2 }, + }, + } as any); + const letterEvent = { + data: { + pageCount: 1, + }, + } as any; + + const result = await filterPacksForLetter(letterEvent, ["spec1"], deps); + + expect(result).toEqual(["spec1"]); + }); + it("throws when no eligible packs found for letter", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { operator: "LESS_THAN", value: 2 }, + }, + } as any); + const letterEvent = { + data: { + pageCount: 3, + }, + } as any; + + await expect( + filterPacksForLetter(letterEvent, ["spec1"], deps), + ).rejects.toThrow( + "No eligible pack specifications found for letter variant id undefined and pack specification ids spec1", + ); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: "No eligible pack specifications found for letter", + letterVariantId: undefined, + packSpecificationIds: ["spec1"], + }), + ); + }); + it("returns eligible packs for all constraint types", async () => { + const deps = makeDeps(); + const constraints: { operator: string; value: number }[] = [ + { operator: "EQUALS", value: 2 }, + { operator: "NOT_EQUALS", value: 1 }, + { operator: "GREATER_THAN", value: 1 }, + { operator: "LESS_THAN", value: 3 }, + { operator: "GREATER_THAN_OR_EQUAL", value: 2 }, + { operator: "LESS_THAN_OR_EQUAL", value: 2 }, + ]; + const letterEvent = { + data: { + pageCount: 2, + }, + } as any; + + for (const constraint of constraints) { + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { + operator: constraint.operator, + value: constraint.value, + }, + }, + } as any); + + const result = await filterPacksForLetter(letterEvent, ["spec1"], deps); + + expect(result).toEqual(["spec1"]); + } + }); + it("throws an error for unsupported operator", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { operator: "UNSUPPORTED_OP", value: 2 }, + }, + } as any); + const letterEvent = { + data: { + pageCount: 2, + }, + } as any; + + await expect( + filterPacksForLetter(letterEvent, ["spec1"], deps), + ).rejects.toThrow( + "Unsupported operator UNSUPPORTED_OP in pack specification constraints", + ); + }); + it("returns all packs when no constraints defined", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + } as any); + const letterEvent = { + data: { + pageCount: 5, + }, + } as any; + + const result = await filterPacksForLetter(letterEvent, ["spec1"], deps); + + expect(result).toEqual(["spec1"]); + }); + }); }); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 31db9660f..1eaa1fc48 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -6,8 +6,13 @@ import { SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; + import { Deps } from "../config/deps"; +type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; + export async function getVariantDetails( variantId: string, deps: Deps, @@ -188,3 +193,88 @@ export async function getSuppliersWithValidPack( return suppliersWithValidPack; } + +function evaluateContraint( + actualValue: number, + constraintValue: number, + operator: string, +): boolean { + console.log( + `Evaluating constraint: actualValue ${actualValue}, constraintValue ${constraintValue}, operator ${operator}`, + ); + switch (operator) { + case "EQUALS": { + return actualValue === constraintValue; + } + case "NOT_EQUALS": { + return actualValue !== constraintValue; + } + case "GREATER_THAN": { + return actualValue > constraintValue; + } + case "LESS_THAN": { + return actualValue < constraintValue; + } + case "GREATER_THAN_OR_EQUAL": { + return actualValue >= constraintValue; + } + case "LESS_THAN_OR_EQUAL": { + return actualValue <= constraintValue; + } + default: { + throw new Error( + `Unsupported operator ${operator} in pack specification constraints`, + ); + } + } +} + +// This function is used to filter the pack specifications for a letter based on the letter data pages and pack specification constraints sheets + +export async function filterPacksForLetter( + letterEvent: PreparedEvents, + packSpecificationIds: string[], + deps: Deps, +): Promise { + const filteredPackIds: string[] = []; + for (const packSpecId of packSpecificationIds) { + const packSpec = + await deps.supplierConfigRepo.getPackSpecification(packSpecId); + if ( + !packSpec.constraints || + !packSpec.constraints.sheets || + !packSpec.constraints.sheets.value || + !packSpec.constraints.sheets.operator + ) { + filteredPackIds.push(packSpecId); + } else { + deps.logger.info({ + description: "Evaluating pack specification constraints for letter", + letterVariantId: letterEvent.data.letterVariantId, + packSpecId, + pageCount: letterEvent.data.pageCount, + constraintValue: packSpec.constraints.sheets.value, + constraintOperator: packSpec.constraints.sheets.operator, + }); + const isValid = evaluateContraint( + letterEvent.data.pageCount, + packSpec.constraints.sheets.value, + packSpec.constraints.sheets.operator, + ); + if (isValid) { + filteredPackIds.push(packSpecId); + } + } + } + if (filteredPackIds.length === 0) { + deps.logger.error({ + description: "No eligible pack specifications found for letter", + letterVariantId: letterEvent.data.letterVariantId, + packSpecificationIds, + }); + throw new Error( + `No eligible pack specifications found for letter variant id ${letterEvent.data.letterVariantId} and pack specification ids ${packSpecificationIds.join(", ")}`, + ); + } + return filteredPackIds; +} From c92b5199848d8a7c7b4f976de84fdf711db27b51 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 14 Apr 2026 14:33:34 +0100 Subject: [PATCH 03/25] CCM-13372 - Update pk and sk values --- .../components/api/ddb_table_supplier_configuration.tf | 2 +- internal/datastore/src/__test__/db.ts | 2 +- .../src/__test__/supplier-config-repository.test.ts | 8 ++++---- internal/datastore/src/supplier-config-repository.ts | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf index e72f9543e..2271ce7ed 100644 --- a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf +++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf @@ -39,7 +39,7 @@ resource "aws_dynamodb_table" "supplier-configuration" { global_secondary_index { name = "packSpecificationId-index" - hash_key = "PK" + hash_key = "pk" range_key = "packSpecificationId" projection_type = "ALL" } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 6f50af34d..de00a6b16 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -168,7 +168,7 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ { IndexName: "packSpecificationId-index", KeySchema: [ - { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "pk", KeyType: "HASH" }, // Partition key for GSI { AttributeName: "packSpecificationId", KeyType: "RANGE" }, // Sort key for GSI ], Projection: { diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index b2beea82e..74bea98c3 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -273,8 +273,8 @@ describe("SupplierConfigRepository", () => { new PutCommand({ TableName: dbContext.config.supplierConfigTableName, Item: { - PK: "SUPPLIER_PACK", - SK: supplierPackId, + pk: "ENTITY#supplier-pack", + sk: `ID#${supplierPackId}`, id: supplierPackId, packSpecificationId: packSpecId, supplierId, @@ -311,8 +311,8 @@ describe("SupplierConfigRepository", () => { new PutCommand({ TableName: dbContext.config.supplierConfigTableName, Item: { - PK: "PACK_SPECIFICATION", - SK: packSpecId, + pk: "ENTITY#pack_specification", + sk: `ID#${packSpecId}`, id: packSpecId, name: `Pack Specification ${packSpecId}`, createdAt: new Date().toISOString(), diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index d7a644b52..c4648a63c 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -112,13 +112,13 @@ export class SupplierConfigRepository { KeyConditionExpression: "#pk = :pk AND #packSpecId = :packSpecId", FilterExpression: "#status = :status AND #approval = :approval", ExpressionAttributeNames: { - "#pk": "PK", + "#pk": "pk", "#packSpecId": "packSpecificationId", "#status": "status", "#approval": "approval", }, ExpressionAttributeValues: { - ":pk": "SUPPLIER_PACK", + ":pk": "ENTITY#supplier-pack", ":packSpecId": packSpecId, ":status": "PROD", ":approval": "APPROVED", @@ -133,7 +133,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { PK: "PACK_SPECIFICATION", SK: packSpecId }, + Key: { pk: "ENTITY#pack_specification", sk: `ID#${packSpecId}` }, }), ); if (!result.Item) { From d4dbaaf93f971af9804373e6db5fd6d22dd2ff9e Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 16 Apr 2026 09:19:13 +0100 Subject: [PATCH 04/25] CCM-13882 - Calculate-Supplier-Weighting --- .../api/ddb_table_supplier_quotas.tf | 49 +++++++++++++++++++ .../terraform/components/api/locals.tf | 3 +- .../api/module_lambda_supplier_allocator.tf | 4 +- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf new file mode 100644 index 000000000..663b27975 --- /dev/null +++ b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf @@ -0,0 +1,49 @@ +resource "aws_dynamodb_table" "supplier-quotas" { + name = "${local.csi}-supplier-quotas" + billing_mode = "PAY_PER_REQUEST" + + hash_key = "pk" + range_key = "sk" + + ttl { + attribute_name = "ttl" + enabled = true + } + + attribute { + name = "pk" + type = "S" + } + + attribute { + name = "sk" + type = "S" + } + + attribute { + name = "entityType" + type = "S" + } + + + + // The type-index GSI allows us to query for all supplier quotas of a given type (e.g. all supplier daily quotas) + global_secondary_index { + name = "EntityTypeIndex" + hash_key = "entityType" + range_key = "sk" + projection_type = "ALL" + } + + point_in_time_recovery { + enabled = true + } + + tags = merge( + local.default_tags, + { + NHSE-Enable-Dynamo-Backup-Acct = "True" + } + ) + +} diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 111925095..6975e8d4e 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -33,7 +33,8 @@ locals { MI_TABLE_NAME = aws_dynamodb_table.mi.name, MI_TTL_HOURS = 2160 # 90 days * 24 hours SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}", - SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name + SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name, + SUPPLIER_QUOTAS_TABLE_NAME = aws_dynamodb_table.supplier-quotas.name, SUPPLIER_ID_HEADER = "nhsd-supplier-id", } diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index c2013fb6f..ee9924a9d 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -94,7 +94,9 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { resources = [ aws_dynamodb_table.supplier-configuration.arn, - "${aws_dynamodb_table.supplier-configuration.arn}/index/*" + aws_dynamodb_table.supplier-quotas.arn, + "${aws_dynamodb_table.supplier-configuration.arn}/index/*", + "${aws_dynamodb_table.supplier-quotas.arn}/index/*" ] } } From fd3c6234ca0abd5bbce8f70789d2952f93112ca7 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 16 Apr 2026 16:02:28 +0100 Subject: [PATCH 05/25] CCM-13384 calculate allocation factors --- .../supplier-config-repository.test.ts | 2 +- internal/datastore/src/index.ts | 1 + .../src/supplier-config-repository.ts | 2 +- .../src/supplier-quotas-repository.ts | 155 ++++++++++++++++++ internal/datastore/src/types.ts | 39 +++++ lambdas/supplier-allocator/jest.config.ts | 2 +- .../src/config/__tests__/deps.test.ts | 10 ++ .../src/config/__tests__/env.test.ts | 2 + lambdas/supplier-allocator/src/config/deps.ts | 19 ++- lambdas/supplier-allocator/src/config/env.ts | 1 + .../__tests__/allocate-handler.test.ts | 60 ++++++- .../src/handler/allocate-handler.ts | 27 +++ .../src/services/supplier-quotas.ts | 49 ++++++ 13 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 internal/datastore/src/supplier-quotas-repository.ts create mode 100644 lambdas/supplier-allocator/src/services/supplier-quotas.ts diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index 74bea98c3..6648b7fb6 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -311,7 +311,7 @@ describe("SupplierConfigRepository", () => { new PutCommand({ TableName: dbContext.config.supplierConfigTableName, Item: { - pk: "ENTITY#pack_specification", + pk: "ENTITY#pack-specification", sk: `ID#${packSpecId}`, id: packSpecId, name: `Pack Specification ${packSpecId}`, diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 9b656d9ee..3ecd72892 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -3,6 +3,7 @@ export * from "./mi-repository"; export * from "./letter-repository"; export * from "./supplier-repository"; export * from "./supplier-config-repository"; +export * from "./supplier-quotas-repository"; export { default as LetterQueueRepository } from "./letter-queue-repository"; export { default as DBHealthcheck } from "./healthcheck"; export { default as LetterAlreadyExistsError } from "./errors/letter-already-exists-error"; diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index c4648a63c..46794c0c1 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -133,7 +133,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { pk: "ENTITY#pack_specification", sk: `ID#${packSpecId}` }, + Key: { pk: "ENTITY#pack-specification", sk: `ID#${packSpecId}` }, }), ); if (!result.Item) { diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts new file mode 100644 index 000000000..0231dea2c --- /dev/null +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -0,0 +1,155 @@ +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, + UpdateCommand, +} from "@aws-sdk/lib-dynamodb"; +import { + $DailyAllocation, + $OverallAllocation, + DailyAllocation, + OverallAllocation, +} from "./types"; + +export type SupplierQuotasRepositoryConfig = { + supplierQuotasTableName: string; +}; + +function ItemForRecord( + entity: string, + id: string, + record: Record, +): Record { + return { + pk: `ENTITY#${entity}`, + sk: `ID#${id}`, + ...record, + }; +} + +export class SupplierQuotasRepository { + constructor( + readonly ddbClient: DynamoDBDocumentClient, + readonly config: SupplierQuotasRepositoryConfig, + ) {} + + async getOverallAllocation(groupId: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, + }), + ); + if (!result.Item) { + throw new Error( + `No overall allocation found for volume group id ${groupId}`, + ); + } + return $OverallAllocation.parse(result.Item); + } + + async putOverallAllocation(allocation: OverallAllocation): Promise { + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "overall-allocation", + allocation.id, + $OverallAllocation.parse(allocation), + ), + }), + ); + } + + // Update the overallAllocation table updating the allocations array for a given volume group + // or adding the value if the supplier is not present // + async updateOverallAllocation( + groupId: string, + supplierId: string, + newAllocation: number, + ): Promise { + const overallAllocation = await this.getOverallAllocation(groupId); + const currentAllocation = overallAllocation.allocations[supplierId] ?? 0; + const updatedAllocation = currentAllocation + newAllocation; + + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, + UpdateExpression: + "SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt", + ExpressionAttributeNames: { + "#supplierId": supplierId, + }, + ExpressionAttributeValues: { + ":updatedAllocation": updatedAllocation, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } + + async getDailyAllocation( + groupId: string, + date: string, + ): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${groupId}#DATE#${date}`, + }, + }), + ); + if (!result.Item) { + throw new Error( + `No daily allocation found for volume group id ${groupId} and date ${date}`, + ); + } + return $DailyAllocation.parse(result.Item); + } + + async putDailyAllocation(allocation: DailyAllocation): Promise { + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "daily-allocation", + `${allocation.volumeGroup}#DATE#${allocation.date}`, + $DailyAllocation.parse(allocation), + ), + }), + ); + } + + async updateDailyAllocation( + groupId: string, + date: string, + supplierId: string, + newAllocation: number, + ): Promise { + const dailyAllocation = await this.getDailyAllocation(groupId, date); + const currentAllocation = dailyAllocation.allocations[supplierId] ?? 0; + const updatedAllocation = currentAllocation + newAllocation; + + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${groupId}#DATE#${date}`, + }, + UpdateExpression: + "SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt", + ExpressionAttributeNames: { + "#supplierId": supplierId, + }, + ExpressionAttributeValues: { + ":updatedAllocation": updatedAllocation, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } +} diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 730f91177..25ba40d8b 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import { idRef } from "@internal/helpers"; +import { + $Supplier, + $VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; export const SupplierStatus = z.enum(["ENABLED", "DISABLED"]); @@ -120,3 +124,38 @@ export const MISchema = MISchemaBase.extend({ export type MI = z.infer; export type MIBase = z.infer; + +export const $OverallAllocation = z + .object({ + id: z.string(), + volumeGroup: idRef($VolumeGroup, "id"), + allocations: z.record( + idRef($Supplier, "id"), + z.number().int().nonnegative(), + ), + }) + .meta({ + title: "OverallAllocation", + description: + "The overall allocation for a volume group, including all suppliers", + }); + +export type OverallAllocation = z.infer; + +export const $DailyAllocation = z + .object({ + id: z.string(), + date: z.ZodISODate, + volumeGroup: idRef($VolumeGroup, "id"), + allocations: z.record( + idRef($Supplier, "id"), + z.number().int().nonnegative(), + ), + }) + .meta({ + title: "DailyAllocation", + description: + "The daily allocation for a volume group, including all suppliers", + }); + +export type DailyAllocation = z.infer; diff --git a/lambdas/supplier-allocator/jest.config.ts b/lambdas/supplier-allocator/jest.config.ts index 872794514..9f16a04f2 100644 --- a/lambdas/supplier-allocator/jest.config.ts +++ b/lambdas/supplier-allocator/jest.config.ts @@ -14,7 +14,7 @@ export const baseJestConfig = { clearMocks: true, // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + collectCoverage: false, // The directory where Jest should output its coverage files coverageDirectory: "./.reports/unit/coverage", diff --git a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts index 7c2767f11..6cd95d077 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts @@ -3,6 +3,7 @@ import type { Deps } from "lambdas/supplier-allocator/src/config/deps"; describe("createDependenciesContainer", () => { const env = { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", @@ -30,6 +31,7 @@ describe("createDependenciesContainer", () => { // Repo client jest.mock("@internal/datastore", () => ({ SupplierConfigRepository: jest.fn(), + SupplierQuotasRepository: jest.fn(), })); // Env @@ -42,6 +44,9 @@ describe("createDependenciesContainer", () => { const { SupplierConfigRepository } = jest.requireMock( "@internal/datastore", ); + const { SupplierQuotasRepository } = jest.requireMock( + "@internal/datastore", + ); // eslint-disable-next-line @typescript-eslint/no-require-imports const { createDependenciesContainer } = require("../deps"); const deps: Deps = createDependenciesContainer(); @@ -51,6 +56,11 @@ describe("createDependenciesContainer", () => { expect(supplierConfigRepoCtorArgs[1]).toEqual({ supplierConfigTableName: "SupplierConfigTable", }); + expect(SupplierQuotasRepository).toHaveBeenCalledTimes(1); + const supplierQuotasRepoCtorArgs = SupplierQuotasRepository.mock.calls[0]; + expect(supplierQuotasRepoCtorArgs[1]).toEqual({ + supplierQuotasTableName: "SupplierQuotasTable", + }); expect(deps.env).toEqual(env); }); }); diff --git a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts index 43c4d5bb9..78e2d0a6a 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts @@ -16,6 +16,7 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable"; + process.env.SUPPLIER_QUOTAS_TABLE_NAME = "SupplierQuotasTable"; process.env.VARIANT_MAP = `{ "lv1": { "supplierId": "supplier1", @@ -29,6 +30,7 @@ describe("lambdaEnv", () => { expect(envVars).toEqual({ SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", diff --git a/lambdas/supplier-allocator/src/config/deps.ts b/lambdas/supplier-allocator/src/config/deps.ts index 4d51f9a07..5f58a00e0 100644 --- a/lambdas/supplier-allocator/src/config/deps.ts +++ b/lambdas/supplier-allocator/src/config/deps.ts @@ -3,11 +3,15 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { SQSClient } from "@aws-sdk/client-sqs"; import { Logger } from "pino"; import { createLogger } from "@internal/helpers"; -import { SupplierConfigRepository } from "@internal/datastore"; +import { + SupplierConfigRepository, + SupplierQuotasRepository, +} from "@internal/datastore"; import { EnvVars, envVars } from "./env"; export type Deps = { supplierConfigRepo: SupplierConfigRepository; + supplierQuotasRepo: SupplierQuotasRepository; logger: Logger; env: EnvVars; sqsClient: SQSClient; @@ -30,11 +34,24 @@ function createSupplierConfigRepository( return new SupplierConfigRepository(createDocumentClient(), config); } +function createSupplierQuotasRepository( + log: Logger, + // eslint-disable-next-line @typescript-eslint/no-shadow + envVars: EnvVars, +): SupplierQuotasRepository { + const config = { + supplierQuotasTableName: envVars.SUPPLIER_QUOTAS_TABLE_NAME, + }; + + return new SupplierQuotasRepository(createDocumentClient(), config); +} + export function createDependenciesContainer(): Deps { const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL }); return { supplierConfigRepo: createSupplierConfigRepository(log, envVars), + supplierQuotasRepo: createSupplierQuotasRepository(log, envVars), logger: log, env: envVars, sqsClient: new SQSClient({}), diff --git a/lambdas/supplier-allocator/src/config/env.ts b/lambdas/supplier-allocator/src/config/env.ts index e6959999c..657d95b88 100644 --- a/lambdas/supplier-allocator/src/config/env.ts +++ b/lambdas/supplier-allocator/src/config/env.ts @@ -13,6 +13,7 @@ export type LetterVariant = z.infer; const EnvVarsSchema = z.object({ SUPPLIER_CONFIG_TABLE_NAME: z.string(), + SUPPLIER_QUOTAS_TABLE_NAME: z.string(), PINO_LOG_LEVEL: z.coerce.string().optional(), VARIANT_MAP: z.string().transform((str, _) => { const parsed = JSON.parse(str); diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 0b7f452f1..60ff9d51d 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -7,9 +7,13 @@ import { $LetterStatusChangeEvent, LetterStatusChangeEvent, } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; -import { SupplierConfigRepository } from "@internal/datastore"; +import { + SupplierConfigRepository, + SupplierQuotasRepository, +} from "@internal/datastore"; import createSupplierAllocatorHandler from "../allocate-handler"; import * as supplierConfig from "../../services/supplier-config"; +import * as supplierQuotas from "../../services/supplier-quotas"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; @@ -21,6 +25,7 @@ const renderingSchemaVersion: string = ]; jest.mock("../../services/supplier-config"); +jest.mock("../../services/supplier-quotas"); function createSQSEvent(records: SQSRecord[]): SQSEvent { return { @@ -169,12 +174,19 @@ function setupDefaultMocks() { colour: false, duplex: false, }); + ( + supplierQuotas.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue({ + supplierId: "supplier-1", + factor: 0.5, + }); } describe("createSupplierAllocatorHandler", () => { let mockSqsClient: jest.Mocked; let mockedDeps: jest.Mocked; let mockedSupplierConfigRepo: jest.Mocked; + let mockedSupplierQuotasRepo: jest.Mocked; beforeEach(() => { mockSqsClient = { send: jest.fn(), @@ -191,10 +203,22 @@ describe("createSupplierAllocatorHandler", () => { getPackSpecification: jest.fn(), } as jest.Mocked; + mockedSupplierQuotasRepo = { + ddbClient: {} as any, + config: {} as any, + getOverallAllocation: jest.fn(), + putOverallAllocation: jest.fn(), + updateOverallAllocation: jest.fn(), + getDailyAllocation: jest.fn(), + putDailyAllocation: jest.fn(), + updateDailyAllocation: jest.fn(), + } as jest.Mocked; + mockedDeps = { logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger, env: { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", @@ -206,6 +230,7 @@ describe("createSupplierAllocatorHandler", () => { } as EnvVars, sqsClient: mockSqsClient, supplierConfigRepo: mockedSupplierConfigRepo, + supplierQuotasRepo: mockedSupplierQuotasRepo, } as jest.Mocked; jest.clearAllMocks(); }); @@ -434,6 +459,39 @@ describe("createSupplierAllocatorHandler", () => { ); }); + test("returns batch failure when variant mapping is missing for multiple events", async () => { + const preparedEvent1 = createPreparedV2Event(); + preparedEvent1.data.letterVariantId = "missing-variant1"; + const preparedEvent2 = createPreparedV2Event(); + preparedEvent2.data.letterVariantId = "missing-variant2"; + + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg1", JSON.stringify(preparedEvent1)), + createSqsRecord("msg2", JSON.stringify(preparedEvent2)), + ]); + + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + + // Override variant map to be empty for this test + mockedDeps.env.VARIANT_MAP = {} as any; + + const handler = createSupplierAllocatorHandler(mockedDeps); + const result = await handler(evt, {} as any, {} as any); + if (!result) throw new Error("expected BatchResponse, got void"); + + expect(result.batchItemFailures).toHaveLength(2); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); + expect(result.batchItemFailures[1].itemIdentifier).toBe("msg2"); + expect( + (mockedDeps.logger.error as jest.Mock).mock.calls.length, + ).toBeGreaterThan(0); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( + expect.objectContaining({ + description: "No supplier mapping found for variant", + }), + ); + }); + test("handles SQS send errors and returns batch failure", async () => { const preparedEvent = createPreparedV2Event(); diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 7475b9565..eb4c6359e 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -23,6 +23,7 @@ import { getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; +import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; import { Deps } from "../config/deps"; type SupplierSpec = { @@ -116,6 +117,28 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { deps, ); + let supplierAllocationsForPack: SupplierAllocation[] = []; + let supplierFactors: { supplierId: string; factor: number }[] = []; + + if (suppliersForPack && suppliersForPack.length > 0) { + supplierAllocationsForPack = supplierAllocations.filter((alloc) => + suppliersForPack.some((supplier) => supplier.id === alloc.supplier), + ); + + console.log("Supplier allocations for pack", { + supplierAllocationsForPack, + }); + + supplierFactors = await calculateSupplierAllocatedFactor( + supplierAllocationsForPack, + deps, + ); + + console.log("Supplier factors calculated for allocation", { + supplierFactors, + }); + } + deps.logger.info({ description: "Fetched supplier details for supplier allocations", variantId: letterEvent.data.letterVariantId, @@ -127,6 +150,8 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { preferredSupplierPacks, preferredPack, suppliersForPack, + supplierAllocationsForPack, + supplierFactors, }); return allocatedSuppliers; @@ -184,6 +209,8 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { const perAllocationSuccess: AllocationMetrics = new Map(); const perAllocationFailure: AllocationMetrics = new Map(); + // Initialise the supplier quotas. + const tasks = event.Records.map(async (record) => { let supplier = "unknown"; let priority = "unknown"; diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts new file mode 100644 index 000000000..462cbac97 --- /dev/null +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -0,0 +1,49 @@ +import { OverallAllocation } from "@internal/datastore"; +import { SupplierAllocation } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { Deps } from "../config/deps"; + +export async function calculateSupplierAllocatedFactor( + supplierAllocations: SupplierAllocation[], + deps: Deps, +): Promise<{ supplierId: string; factor: number }[]> { + const volumeGroupId = supplierAllocations[0].volumeGroup; // Assuming all allocations are for the same volume group + const overallAllocation: OverallAllocation = + await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); + + const { allocations } = overallAllocation; + + const totalAllocation = Object.values(allocations).reduce( + (sum, allocation) => sum + allocation, + 0, + ); + + return supplierAllocations.map((allocation) => { + const supplierAllocation = allocations[allocation.supplier] ?? 0; + const percentage = + totalAllocation > 0 ? (supplierAllocation / totalAllocation) * 100 : 0; + const factor = percentage / allocation.allocationPercentage; + return { supplierId: allocation.supplier, factor }; + }); +} + +export async function updateSupplierQuota( + groupId: string, + supplierId: string, + newAllocation: number, + deps: Deps, +): Promise { + const overallAllocation = + await deps.supplierQuotasRepo.getOverallAllocation(groupId); + + const updatedAllocations = { + ...overallAllocation.allocations, + [supplierId]: newAllocation, + }; + + const updatedOverallAllocation: OverallAllocation = { + ...overallAllocation, + allocations: updatedAllocations, + }; + + await deps.supplierQuotasRepo.putOverallAllocation(updatedOverallAllocation); +} From 94469a6ef7d719527d8e1b2f7b4e11b188c4a462 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 10:33:15 +0100 Subject: [PATCH 06/25] test supplier config --- config/suppliers/supplier-pack/supplier2-notify-c5.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 config/suppliers/supplier-pack/supplier2-notify-c5.json diff --git a/config/suppliers/supplier-pack/supplier2-notify-c5.json b/config/suppliers/supplier-pack/supplier2-notify-c5.json new file mode 100644 index 000000000..b012f02f0 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier2-notify-c5.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier2-notify-c5", + "packSpecificationId": "notify-c5", + "status": "PROD", + "supplierId": "supplier2" +} From 03b75c932833adc6b79b3c741ec4c6bb7e481886 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 11:00:08 +0100 Subject: [PATCH 07/25] handle non existent overall allocations --- .../src/supplier-quotas-repository.ts | 13 ++++--- .../src/services/supplier-quotas.ts | 35 ++++++++++++++----- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index 0231dea2c..0f6fe9ffc 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -33,7 +33,9 @@ export class SupplierQuotasRepository { readonly config: SupplierQuotasRepositoryConfig, ) {} - async getOverallAllocation(groupId: string): Promise { + async getOverallAllocation( + groupId: string, + ): Promise { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierQuotasTableName, @@ -41,9 +43,7 @@ export class SupplierQuotasRepository { }), ); if (!result.Item) { - throw new Error( - `No overall allocation found for volume group id ${groupId}`, - ); + return undefined; } return $OverallAllocation.parse(result.Item); } @@ -69,7 +69,10 @@ export class SupplierQuotasRepository { newAllocation: number, ): Promise { const overallAllocation = await this.getOverallAllocation(groupId); - const currentAllocation = overallAllocation.allocations[supplierId] ?? 0; + const allocations = overallAllocation?.allocations ?? {}; + const currentAllocation = Object.hasOwn(allocations, supplierId) + ? allocations[supplierId] + : 0; const updatedAllocation = currentAllocation + newAllocation; await this.ddbClient.send( diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index 462cbac97..e966095ef 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -7,9 +7,16 @@ export async function calculateSupplierAllocatedFactor( deps: Deps, ): Promise<{ supplierId: string; factor: number }[]> { const volumeGroupId = supplierAllocations[0].volumeGroup; // Assuming all allocations are for the same volume group - const overallAllocation: OverallAllocation = + const overallAllocation = await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); + if (!overallAllocation) { + return supplierAllocations.map((allocation) => ({ + supplierId: allocation.supplier, + factor: 0, + })); + } + const { allocations } = overallAllocation; const totalAllocation = Object.values(allocations).reduce( @@ -35,15 +42,25 @@ export async function updateSupplierQuota( const overallAllocation = await deps.supplierQuotasRepo.getOverallAllocation(groupId); - const updatedAllocations = { - ...overallAllocation.allocations, - [supplierId]: newAllocation, - }; + const updatedAllocations = overallAllocation + ? { + ...overallAllocation.allocations, + [supplierId]: newAllocation, + } + : { + [supplierId]: newAllocation, + }; - const updatedOverallAllocation: OverallAllocation = { - ...overallAllocation, - allocations: updatedAllocations, - }; + const updatedOverallAllocation: OverallAllocation = overallAllocation + ? { + ...overallAllocation, + allocations: updatedAllocations, + } + : { + id: groupId, + volumeGroup: groupId, + allocations: updatedAllocations, + }; await deps.supplierQuotasRepo.putOverallAllocation(updatedOverallAllocation); } From 5ab1cde174c14cdc34ae3f14da142e5c72913522 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 14:25:03 +0100 Subject: [PATCH 08/25] store current allocations --- .../src/supplier-quotas-repository.ts | 9 +- .../src/handler/allocate-handler.ts | 86 +++++++++++++++++-- .../src/services/supplier-quotas.ts | 68 +++++++++------ 3 files changed, 127 insertions(+), 36 deletions(-) diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index 0f6fe9ffc..e1212e924 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -95,7 +95,7 @@ export class SupplierQuotasRepository { async getDailyAllocation( groupId: string, date: string, - ): Promise { + ): Promise { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierQuotasTableName, @@ -106,9 +106,7 @@ export class SupplierQuotasRepository { }), ); if (!result.Item) { - throw new Error( - `No daily allocation found for volume group id ${groupId} and date ${date}`, - ); + return undefined; } return $DailyAllocation.parse(result.Item); } @@ -133,7 +131,8 @@ export class SupplierQuotasRepository { newAllocation: number, ): Promise { const dailyAllocation = await this.getDailyAllocation(groupId, date); - const currentAllocation = dailyAllocation.allocations[supplierId] ?? 0; + const allocations = dailyAllocation?.allocations ?? {}; + const currentAllocation = allocations[supplierId] ?? 0; const updatedAllocation = currentAllocation + newAllocation; await this.ddbClient.send( diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index eb4c6359e..22d6fbc84 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -23,7 +23,10 @@ import { getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; -import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; +import { + calculateSupplierAllocatedFactor, + updateSupplierAllocation, +} from "../services/supplier-quotas"; import { Deps } from "../config/deps"; type SupplierSpec = { @@ -32,6 +35,12 @@ type SupplierSpec = { priority: number; billingId: string; }; + +type SupplierDetails = { + supplierSpec: SupplierSpec; + volumeGroupId: string; +}; + type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; // small envelope that must exist in all inputs @@ -71,7 +80,10 @@ function validateType(event: unknown) { } } -async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { +async function getSupplierFromConfig( + letterEvent: PreparedEvents, + deps: Deps, +): Promise { try { const variantDetails: LetterVariant = await getVariantDetails( letterEvent.data.letterVariantId, @@ -119,7 +131,7 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { let supplierAllocationsForPack: SupplierAllocation[] = []; let supplierFactors: { supplierId: string; factor: number }[] = []; - + let selectedSupplierId = "unknown"; // Default to first supplier if no allocations or factors can be calculated if (suppliersForPack && suppliersForPack.length > 0) { supplierAllocationsForPack = supplierAllocations.filter((alloc) => suppliersForPack.some((supplier) => supplier.id === alloc.supplier), @@ -137,6 +149,16 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { console.log("Supplier factors calculated for allocation", { supplierFactors, }); + + // Get the supplierid with the lowest factor + selectedSupplierId = supplierFactors[0].supplierId; + let lowestFactor = supplierFactors[0].factor; + for (const supplierFactor of supplierFactors) { + if (supplierFactor.factor < lowestFactor) { + lowestFactor = supplierFactor.factor; + selectedSupplierId = supplierFactor.supplierId; + } + } } deps.logger.info({ @@ -152,16 +174,26 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { suppliersForPack, supplierAllocationsForPack, supplierFactors, + selectedSupplierId, }); - return allocatedSuppliers; + const supplierDetails: SupplierDetails = { + supplierSpec: { + supplierId: selectedSupplierId, + specId: preferredPack.id, + priority: 0, + billingId: preferredPack.billingId, + }, + volumeGroupId: volumeGroupDetails.id, + }; + return supplierDetails; } catch (error) { deps.logger.error({ description: "Error fetching supplier from config", err: error, variantId: letterEvent.data.letterVariantId, }); - return []; + return undefined; } } @@ -170,6 +202,7 @@ function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec { } type AllocationMetrics = Map>; +type VolumeGroupAllocation = Map>; function incrementMetric( map: AllocationMetrics, @@ -203,12 +236,40 @@ function emitMetrics( } } +function incrementAllocation( + map: VolumeGroupAllocation, + volumeGroupId: string, + supplierId: string, + allocation: number, +) { + const groupAllocations = map.get(volumeGroupId) ?? {}; + groupAllocations[supplierId] = + (groupAllocations[supplierId] ?? 0) + allocation; + map.set(volumeGroupId, groupAllocations); +} + +async function saveAllocations( + deps: Deps, + volumeGroupAllocations: VolumeGroupAllocation, +) { + for (const [volumeGroupId, allocations] of volumeGroupAllocations) { + for (const [supplierId, allocation] of Object.entries(allocations)) { + await updateSupplierAllocation( + volumeGroupId, + supplierId, + allocation, + deps, + ); + } + } +} + export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { return async (event: SQSEvent) => { const batchItemFailures: SQSBatchItemFailure[] = []; const perAllocationSuccess: AllocationMetrics = new Map(); const perAllocationFailure: AllocationMetrics = new Map(); - + const volumeGroupAllocations: VolumeGroupAllocation = new Map(); // Map of volume group id to supplier allocations for that group, used to track the allocations calculated in this batch for emitting metrics and updating the quotas after processing the batch // Initialise the supplier quotas. const tasks = event.Records.map(async (record) => { @@ -225,7 +286,17 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { validateType(letterEvent); const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps); - await getSupplierFromConfig(letterEvent as PreparedEvents, deps); + const supplierDetails = await getSupplierFromConfig( + letterEvent as PreparedEvents, + deps, + ); + + incrementAllocation( + volumeGroupAllocations, + supplierDetails?.volumeGroupId ?? "unknown", + supplierSpec.supplierId, + 1, + ); supplier = supplierSpec.supplierId; priority = String(supplierSpec.priority); @@ -276,6 +347,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { emitMetrics(perAllocationSuccess, MetricStatus.Success, deps); emitMetrics(perAllocationFailure, MetricStatus.Failure, deps); + await saveAllocations(deps, volumeGroupAllocations); return { batchItemFailures }; }; } diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index e966095ef..3c42c51b6 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -1,4 +1,4 @@ -import { OverallAllocation } from "@internal/datastore"; +import { DailyAllocation, OverallAllocation } from "@internal/datastore"; import { SupplierAllocation } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { Deps } from "../config/deps"; @@ -33,34 +33,54 @@ export async function calculateSupplierAllocatedFactor( }); } -export async function updateSupplierQuota( - groupId: string, +// function to either update or create a new overall allocation and daily allocation for a given supplier, volume group and allocation amount +// if the overall allocation for the volume group does not exist, it will be created with the new allocation for the supplier and 0 for the other suppliers + +export async function updateSupplierAllocation( + volumeGroupId: string, supplierId: string, newAllocation: number, deps: Deps, ): Promise { const overallAllocation = - await deps.supplierQuotasRepo.getOverallAllocation(groupId); - - const updatedAllocations = overallAllocation - ? { - ...overallAllocation.allocations, + await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); + if (overallAllocation) { + await deps.supplierQuotasRepo.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + } else { + const newOverallAllocation: OverallAllocation = { + id: volumeGroupId, + volumeGroup: volumeGroupId, + allocations: { [supplierId]: newAllocation, - } - : { + }, + }; + await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); + } + const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format + const dailyAllocation = await deps.supplierQuotasRepo.getDailyAllocation( + volumeGroupId, + dailyAllocationDate, + ); + if (dailyAllocation) { + await deps.supplierQuotasRepo.updateDailyAllocation( + volumeGroupId, + dailyAllocationDate, + supplierId, + newAllocation, + ); + } else { + const newDailyAllocation: DailyAllocation = { + id: `${volumeGroupId}#DATE#${dailyAllocationDate}`, + date: dailyAllocationDate, + volumeGroup: volumeGroupId, + allocations: { [supplierId]: newAllocation, - }; - - const updatedOverallAllocation: OverallAllocation = overallAllocation - ? { - ...overallAllocation, - allocations: updatedAllocations, - } - : { - id: groupId, - volumeGroup: groupId, - allocations: updatedAllocations, - }; - - await deps.supplierQuotasRepo.putOverallAllocation(updatedOverallAllocation); + }, + }; + await deps.supplierQuotasRepo.putDailyAllocation(newDailyAllocation); + } } From 24e4ec7233530c034cc8c207a2b7ea83a8103c57 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 15:01:17 +0100 Subject: [PATCH 09/25] debug logging --- .../src/handler/allocate-handler.ts | 24 ++++++++++++------- .../src/services/supplier-config.ts | 3 --- .../src/services/supplier-quotas.ts | 11 +++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 22d6fbc84..50e787723 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -137,19 +137,11 @@ async function getSupplierFromConfig( suppliersForPack.some((supplier) => supplier.id === alloc.supplier), ); - console.log("Supplier allocations for pack", { - supplierAllocationsForPack, - }); - supplierFactors = await calculateSupplierAllocatedFactor( supplierAllocationsForPack, deps, ); - console.log("Supplier factors calculated for allocation", { - supplierFactors, - }); - // Get the supplierid with the lowest factor selectedSupplierId = supplierFactors[0].supplierId; let lowestFactor = supplierFactors[0].factor; @@ -241,17 +233,27 @@ function incrementAllocation( volumeGroupId: string, supplierId: string, allocation: number, + deps: Deps, ) { const groupAllocations = map.get(volumeGroupId) ?? {}; groupAllocations[supplierId] = (groupAllocations[supplierId] ?? 0) + allocation; map.set(volumeGroupId, groupAllocations); + deps.logger.info({ + description: "Updated allocations for volume group and supplier", + volumeGroupId, + groupAllocations, + }); } async function saveAllocations( deps: Deps, volumeGroupAllocations: VolumeGroupAllocation, ) { + deps.logger.info({ + description: "Saving supplier allocations for volume groups", + volumeGroupAllocations, + }); for (const [volumeGroupId, allocations] of volumeGroupAllocations) { for (const [supplierId, allocation] of Object.entries(allocations)) { await updateSupplierAllocation( @@ -291,11 +293,17 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { deps, ); + deps.logger.info({ + description: "Resolved supplier details from config", + supplierDetails, + }); + incrementAllocation( volumeGroupAllocations, supplierDetails?.volumeGroupId ?? "unknown", supplierSpec.supplierId, 1, + deps, ); supplier = supplierSpec.supplierId; diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 1eaa1fc48..c86640513 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -199,9 +199,6 @@ function evaluateContraint( constraintValue: number, operator: string, ): boolean { - console.log( - `Evaluating constraint: actualValue ${actualValue}, constraintValue ${constraintValue}, operator ${operator}`, - ); switch (operator) { case "EQUALS": { return actualValue === constraintValue; diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index 3c42c51b6..393a0c182 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -45,6 +45,11 @@ export async function updateSupplierAllocation( const overallAllocation = await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); if (overallAllocation) { + deps.logger.info({ + description: "Existing overall allocation found for volume group", + volumeGroupId, + overallAllocation, + }); await deps.supplierQuotasRepo.updateOverallAllocation( volumeGroupId, supplierId, @@ -58,6 +63,12 @@ export async function updateSupplierAllocation( [supplierId]: newAllocation, }, }; + deps.logger.info({ + description: + "No overall allocation found for volume group, creating new one", + volumeGroupId, + newOverallAllocation, + }); await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); } const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format From c2086525b625c2dbc3d0ab58a5164e47cba5cbea Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 15:39:40 +0100 Subject: [PATCH 10/25] error checking --- .../src/supplier-quotas-repository.ts | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index e1212e924..48b6faefb 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -49,16 +49,26 @@ export class SupplierQuotasRepository { } async putOverallAllocation(allocation: OverallAllocation): Promise { - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "overall-allocation", - allocation.id, - $OverallAllocation.parse(allocation), - ), - }), - ); + try { + const parsedAllocation = $OverallAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "overall-allocation", + allocation.id, + parsedAllocation, + ), + }), + ); + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to put overall allocation for id ${allocation.id}: ${error.message}`, + ); + } + throw error; + } } // Update the overallAllocation table updating the allocations array for a given volume group @@ -112,16 +122,26 @@ export class SupplierQuotasRepository { } async putDailyAllocation(allocation: DailyAllocation): Promise { - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "daily-allocation", - `${allocation.volumeGroup}#DATE#${allocation.date}`, - $DailyAllocation.parse(allocation), - ), - }), - ); + try { + const parsedAllocation = $DailyAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "daily-allocation", + `${allocation.volumeGroup}#DATE#${allocation.date}`, + parsedAllocation, + ), + }), + ); + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to put daily allocation for volume group ${allocation.volumeGroup} and date ${allocation.date}: ${error.message}`, + ); + } + throw error; + } } async updateDailyAllocation( From 5518047ff1d9e81b68a320626e11a744f28ad3d8 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 16:13:31 +0100 Subject: [PATCH 11/25] lambda permissions --- .../components/api/module_lambda_supplier_allocator.tf | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index ee9924a9d..fc0882302 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -89,7 +89,9 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { actions = [ "dynamodb:GetItem", - "dynamodb:Query" + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:UpdateItem" ] resources = [ From 54c7fc15244af8d16bdfcf62bed9b012bba8c068 Mon Sep 17 00:00:00 2001 From: David Wass Date: Mon, 20 Apr 2026 09:28:14 +0100 Subject: [PATCH 12/25] fix date type --- internal/datastore/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 25ba40d8b..81a0c9e26 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -145,7 +145,7 @@ export type OverallAllocation = z.infer; export const $DailyAllocation = z .object({ id: z.string(), - date: z.ZodISODate, + date: z.string(), volumeGroup: idRef($VolumeGroup, "id"), allocations: z.record( idRef($Supplier, "id"), From 135570c58504f8148e6968ea9f5c223ec7ceb48d Mon Sep 17 00:00:00 2001 From: David Wass Date: Mon, 20 Apr 2026 10:07:56 +0100 Subject: [PATCH 13/25] more logging --- .../supplier-allocator/src/handler/allocate-handler.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 50e787723..ae3216374 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -178,6 +178,10 @@ async function getSupplierFromConfig( }, volumeGroupId: volumeGroupDetails.id, }; + deps.logger.info({ + description: "Resolved supplier details for letter event", + supplierDetails, + }); return supplierDetails; } catch (error) { deps.logger.error({ @@ -235,6 +239,12 @@ function incrementAllocation( allocation: number, deps: Deps, ) { + deps.logger.info({ + description: "Incrementing allocation for volume group and supplier", + volumeGroupId, + supplierId, + allocation, + }); const groupAllocations = map.get(volumeGroupId) ?? {}; groupAllocations[supplierId] = (groupAllocations[supplierId] ?? 0) + allocation; From b2eb83f321a98e74cd3b7904a162e705eb43457c Mon Sep 17 00:00:00 2001 From: David Wass Date: Mon, 20 Apr 2026 10:30:17 +0100 Subject: [PATCH 14/25] increment correct supplier --- lambdas/supplier-allocator/src/handler/allocate-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index ae3216374..31550cbba 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -311,7 +311,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { incrementAllocation( volumeGroupAllocations, supplierDetails?.volumeGroupId ?? "unknown", - supplierSpec.supplierId, + supplierDetails?.supplierSpec?.supplierId ?? "unknown", 1, deps, ); From 6fa47ebc5ea7f6c82d06a92c49a386c543aed88a Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 21 Apr 2026 11:19:32 +0100 Subject: [PATCH 15/25] refactor for clarity --- internal/datastore/package.json | 2 +- lambdas/supplier-allocator/package.json | 2 +- .../__tests__/allocate-handler.test.ts | 31 +++-- .../src/handler/allocate-handler.ts | 108 ++++++--------- .../src/handler/allocation-config.ts | 125 ++++++++++++++++++ .../__tests__/supplier-config.test.ts | 23 ---- .../src/services/supplier-config.ts | 19 +-- package-lock.json | 31 +++-- 8 files changed, 210 insertions(+), 131 deletions(-) create mode 100644 lambdas/supplier-allocator/src/handler/allocation-config.ts diff --git a/internal/datastore/package.json b/internal/datastore/package.json index 76e3f3284..89dbadb5e 100644 --- a/internal/datastore/package.json +++ b/internal/datastore/package.json @@ -3,7 +3,7 @@ "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.1008.0", "@internal/helpers": "*", - "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0", "pino": "^10.3.0", "zod": "^4.1.11", "zod-mermaid": "^1.0.9" diff --git a/lambdas/supplier-allocator/package.json b/lambdas/supplier-allocator/package.json index 22485a858..eae1a896a 100644 --- a/lambdas/supplier-allocator/package.json +++ b/lambdas/supplier-allocator/package.json @@ -8,7 +8,7 @@ "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "2.0.1", "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8", - "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0", "@types/aws-lambda": "^8.10.148", "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 60ff9d51d..a32259b53 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -14,6 +14,7 @@ import { import createSupplierAllocatorHandler from "../allocate-handler"; import * as supplierConfig from "../../services/supplier-config"; import * as supplierQuotas from "../../services/supplier-quotas"; +import * as allocationConfig from "../allocation-config"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; @@ -26,6 +27,7 @@ const renderingSchemaVersion: string = jest.mock("../../services/supplier-config"); jest.mock("../../services/supplier-quotas"); +jest.mock("../allocation-config"); function createSQSEvent(records: SQSRecord[]): SQSEvent { return { @@ -154,26 +156,27 @@ function setupDefaultMocks() { id: "g1", status: "PROD", }); - ( - supplierConfig.getSupplierAllocationsForVolumeGroup as jest.Mock - ).mockResolvedValue([{ supplier: "s1" }]); - (supplierConfig.getSupplierDetails as jest.Mock).mockResolvedValue({ - supplierId: "supplier-1", - specId: "spec-1", - priority: 1, - billingId: "billing-1", + (allocationConfig.eligibleSuppliers as jest.Mock).mockResolvedValue({ + supplierAllocations: [{ supplier: "s1", variantId: "v1" }], + suppliers: [{ id: "s1", name: "Supplier 1", status: "PROD" }], }); - (supplierConfig.getPreferredSupplierPacks as jest.Mock).mockResolvedValue([ - { - packSpecificationId: "pack-spec-1", - }, - ]); - (supplierConfig.getPackSpecification as jest.Mock).mockResolvedValue({ + (allocationConfig.preferredSupplierPack as jest.Mock).mockResolvedValue({ id: "pack-spec-1", type: "A4", colour: false, duplex: false, }); + (allocationConfig.filterSuppliersWithCapacity as jest.Mock).mockResolvedValue( + [{ id: "s1", name: "Supplier 1", status: "PROD" }], + ); + (allocationConfig.selectSupplierByFactor as jest.Mock).mockResolvedValue({ + id: "s1", + name: "Supplier 1", + status: "PROD", + }); + (allocationConfig.suppliersWithValidPack as jest.Mock).mockResolvedValue([ + { id: "s1", name: "Supplier 1", status: "PROD" }, + ]); ( supplierQuotas.calculateSupplierAllocatedFactor as jest.Mock ).mockResolvedValue({ diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 31550cbba..7445e082c 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -5,8 +5,6 @@ import { LetterVariant, PackSpecification, Supplier, - SupplierAllocation, - SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; @@ -14,19 +12,18 @@ import z from "zod"; import { Unit } from "aws-embedded-metrics"; import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers"; import { - filterPacksForLetter, - getPackSpecification, - getPreferredSupplierPacks, - getSupplierAllocationsForVolumeGroup, - getSupplierDetails, - getSuppliersWithValidPack, getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; +import { updateSupplierAllocation } from "../services/supplier-quotas"; import { - calculateSupplierAllocatedFactor, - updateSupplierAllocation, -} from "../services/supplier-quotas"; + eligibleSuppliers, + filterSuppliersWithCapacity, + preferredSupplierPack, + selectSupplierByFactor, + suppliersWithValidPack, +} from "./allocation-config"; + import { Deps } from "../config/deps"; type SupplierSpec = { @@ -85,87 +82,68 @@ async function getSupplierFromConfig( deps: Deps, ): Promise { try { - const variantDetails: LetterVariant = await getVariantDetails( + const letterVariant: LetterVariant = await getVariantDetails( letterEvent.data.letterVariantId, deps, ); - const volumeGroupDetails: VolumeGroup = await getVolumeGroupDetails( - variantDetails.volumeGroupId, + const volumeGroup: VolumeGroup = await getVolumeGroupDetails( + letterVariant.volumeGroupId, deps, ); - const supplierAllocations: SupplierAllocation[] = - await getSupplierAllocationsForVolumeGroup( - variantDetails.volumeGroupId, - deps, - variantDetails.supplierId, - ); + const { supplierAllocations, suppliers: allocatedSuppliers } = + await eligibleSuppliers(volumeGroup, deps); - const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); - - const allocatedSuppliers: Supplier[] = await getSupplierDetails( - supplierIds, - deps, - ); - - const eligiblePacks: string[] = await filterPacksForLetter( + const preferredPack: PackSpecification = await preferredSupplierPack( letterEvent, - variantDetails.packSpecificationIds, - deps, - ); - - const preferredSupplierPacks: SupplierPack[] = - await getPreferredSupplierPacks(eligiblePacks, allocatedSuppliers, deps); - - const preferredPack: PackSpecification = await getPackSpecification( - preferredSupplierPacks[0].packSpecificationId, + allocatedSuppliers, + letterVariant.packSpecificationIds, deps, ); - const suppliersForPack: Supplier[] = await getSuppliersWithValidPack( + const allSuppliersForPack: Supplier[] = await suppliersWithValidPack( allocatedSuppliers, preferredPack.id, deps, ); - let supplierAllocationsForPack: SupplierAllocation[] = []; - let supplierFactors: { supplierId: string; factor: number }[] = []; - let selectedSupplierId = "unknown"; // Default to first supplier if no allocations or factors can be calculated - if (suppliersForPack && suppliersForPack.length > 0) { - supplierAllocationsForPack = supplierAllocations.filter((alloc) => - suppliersForPack.some((supplier) => supplier.id === alloc.supplier), + const suppliersForPackWithCapacity: Supplier[] = + await filterSuppliersWithCapacity( + allSuppliersForPack, + volumeGroup.id, + deps, ); - supplierFactors = await calculateSupplierAllocatedFactor( - supplierAllocationsForPack, + // selected supplier id is determined by first calling selectSupplierByFactor for suppliers with capacity and if nothing is returned tryong again with all suppliers for pack + const selectedSupplierId = + (await selectSupplierByFactor( + suppliersForPackWithCapacity, + supplierAllocations, deps, - ); + )) ?? + (await selectSupplierByFactor( + allSuppliersForPack, + supplierAllocations, + deps, + )); - // Get the supplierid with the lowest factor - selectedSupplierId = supplierFactors[0].supplierId; - let lowestFactor = supplierFactors[0].factor; - for (const supplierFactor of supplierFactors) { - if (supplierFactor.factor < lowestFactor) { - lowestFactor = supplierFactor.factor; - selectedSupplierId = supplierFactor.supplierId; - } - } + if (!selectedSupplierId) { + throw new Error( + "No suppliers found with capacity or valid allocation factor for preferred pack", + ); } deps.logger.info({ description: "Fetched supplier details for supplier allocations", variantId: letterEvent.data.letterVariantId, - volumeGroupId: volumeGroupDetails.id, + volumeGroupId: volumeGroup.id, supplierAllocationIds: supplierAllocations.map((a) => a.id), allocatedSuppliers, - variantPacks: variantDetails.packSpecificationIds, - eligiblePacks, - preferredSupplierPacks, - preferredPack, - suppliersForPack, - supplierAllocationsForPack, - supplierFactors, + allSuppliersForPack: allSuppliersForPack.map((s) => s.id), + suppliersForPackWithCapacity: suppliersForPackWithCapacity.map( + (s) => s.id, + ), selectedSupplierId, }); @@ -176,7 +154,7 @@ async function getSupplierFromConfig( priority: 0, billingId: preferredPack.billingId, }, - volumeGroupId: volumeGroupDetails.id, + volumeGroupId: volumeGroup.id, }; deps.logger.info({ description: "Resolved supplier details for letter event", diff --git a/lambdas/supplier-allocator/src/handler/allocation-config.ts b/lambdas/supplier-allocator/src/handler/allocation-config.ts new file mode 100644 index 000000000..b87338df9 --- /dev/null +++ b/lambdas/supplier-allocator/src/handler/allocation-config.ts @@ -0,0 +1,125 @@ +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { + PackSpecification, + Supplier, + SupplierAllocation, + SupplierPack, + VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { + filterPacksForLetter, + getPackSpecification, + getPreferredSupplierPacks, + getSupplierAllocationsForVolumeGroup, + getSupplierDetails, + getSupplierPacks, +} from "../services/supplier-config"; +import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; +import { Deps } from "../config/deps"; + +type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; + +export async function eligibleSuppliers( + volumeGroup: VolumeGroup, + deps: Deps, +): Promise<{ + supplierAllocations: SupplierAllocation[]; + suppliers: Supplier[]; +}> { + const supplierAllocations = await getSupplierAllocationsForVolumeGroup( + volumeGroup.id, + deps, + ); + const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); + + const suppliers = await getSupplierDetails(supplierIds, deps); + return { supplierAllocations, suppliers }; +} + +export async function preferredSupplierPack( + letterEvent: PreparedEvents, + suppliers: Supplier[], + packSpecificationIds: string[], + deps: Deps, +): Promise { + const eligiblePacks: string[] = await filterPacksForLetter( + letterEvent, + packSpecificationIds, + deps, + ); + const preferredSupplierPacks: SupplierPack[] = + await getPreferredSupplierPacks(eligiblePacks, suppliers, deps); + const preferredPack: PackSpecification = await getPackSpecification( + preferredSupplierPacks[0].packSpecificationId, + deps, + ); + return preferredPack; +} + +// This function is used to filter the allocated suppliers based on those that support the supplied pack specification +export async function suppliersWithValidPack( + suppliers: Supplier[], + packSpecificationId: string, + deps: Deps, +): Promise { + const validSuppliers: Supplier[] = []; + const supplierPacks = await getSupplierPacks(packSpecificationId, deps); + + for (const supplier of suppliers) { + const hasValidPack = supplierPacks.some( + (pack) => pack.supplierId === supplier.id, + ); + if (hasValidPack) { + validSuppliers.push(supplier); + } + } + + return validSuppliers; +} + +export async function filterSuppliersWithCapacity( + suppliers: Supplier[], + volumeGroupId: string, + deps: Deps, +): Promise { + const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format + const dailyAllocation = await deps.supplierQuotasRepo.getDailyAllocation( + volumeGroupId, + dailyAllocationDate, + ); + if (dailyAllocation) { + const suppliersWithCapacity = suppliers.filter((supplier) => { + const allocated = dailyAllocation.allocations[supplier.id] ?? 0; + return allocated < supplier.dailyCapacity; + }); + return suppliersWithCapacity; + } + return suppliers; // If no daily allocation exists, assume all suppliers have capacity +} + +export async function selectSupplierByFactor( + suppliers: Supplier[], + supplierAllocations: SupplierAllocation[], + deps: Deps, +): Promise { + const supplierAllocationsForPack = supplierAllocations.filter((alloc) => + suppliers.some((supplier) => supplier.id === alloc.supplier), + ); + const supplierFactors: { supplierId: string; factor: number }[] = + await calculateSupplierAllocatedFactor(supplierAllocationsForPack, deps); + + deps.logger.info({ + description: "Calculated supplier factors for allocation", + supplierFactors, + }); + let selectedSupplierId = supplierFactors[0].supplierId; + let lowestFactor = supplierFactors[0].factor; + for (const supplierFactor of supplierFactors) { + if (supplierFactor.factor < lowestFactor) { + lowestFactor = supplierFactor.factor; + selectedSupplierId = supplierFactor.supplierId; + } + } + return selectedSupplierId; +} diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index ac6b468d5..4e6f4cd4f 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -4,7 +4,6 @@ import { getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, - getSuppliersWithValidPack, getVariantDetails, getVolumeGroupDetails, } from "../supplier-config"; @@ -459,28 +458,6 @@ describe("supplier-config service", () => { }); }); - describe("getSuppliersWithValidPack", () => { - it("returns suppliers that have the valid pack specification", async () => { - const suppliers = [ - { id: "s1", name: "Supplier 1", status: "PROD" }, - { id: "s2", name: "Supplier 2", status: "PROD" }, - ] as any[]; - const supplierPacks = [ - { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, - ] as any[]; - const deps = makeDeps(); - deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest - .fn() - .mockResolvedValue(supplierPacks); - - const result = await getSuppliersWithValidPack(suppliers, "spec1", deps); - - expect(result).toEqual([ - { id: "s1", name: "Supplier 1", status: "PROD" }, - ]); - }); - }); - describe("filterPacksForLetter", () => { it("returns eligible packs for letter", async () => { const deps = makeDeps(); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index c86640513..191444d01 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -170,28 +170,15 @@ export async function getPackSpecification( return packSpec; } -// This function is used to filter the allocated suppliers based on those that support the supplied pack specification -export async function getSuppliersWithValidPack( - suppliers: Supplier[], +export async function getSupplierPacks( packSpecificationId: string, deps: Deps, -): Promise { - const suppliersWithValidPack: Supplier[] = []; +): Promise { const supplierPacks = await deps.supplierConfigRepo.getSupplierPacksForPackSpecification( packSpecificationId, ); - - for (const supplier of suppliers) { - const hasValidPack = supplierPacks.some( - (pack) => pack.supplierId === supplier.id, - ); - if (hasValidPack) { - suppliersWithValidPack.push(supplier); - } - } - - return suppliersWithValidPack; + return supplierPacks; } function evaluateContraint( diff --git a/package-lock.json b/package-lock.json index b43a56c15..d8d1893b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,12 +93,21 @@ "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/lib-dynamodb": "^3.1008.0", "@internal/helpers": "*", - "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0", "pino": "^10.3.0", "zod": "^4.1.11", "zod-mermaid": "^1.0.9" } }, + "internal/datastore/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { + "version": "1.1.0", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-config/1.1.0/b26c227ea8af22f14e503bc269cffa3687d3aaee", + "integrity": "sha512-j7jT0AClck6eXIFU/FnBrXNNZrib4gDnKfe5vj6zpgVyRa++ReNri2s8cx7GRpghkPoOazzxfsCNAe1rVRAeGA==", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.12" + } + }, "internal/event-builders": { "name": "@internal/event-builders", "version": "1.0.0", @@ -250,7 +259,7 @@ "@nhsdigital/nhs-notify-event-schemas-letter-rendering": "2.0.1", "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8", - "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1", + "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.1.0", "@types/aws-lambda": "^8.10.148", "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", @@ -259,6 +268,15 @@ "zod": "^4.1.11" } }, + "lambdas/supplier-allocator/node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { + "version": "1.1.0", + "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-config/1.1.0/b26c227ea8af22f14e503bc269cffa3687d3aaee", + "integrity": "sha512-j7jT0AClck6eXIFU/FnBrXNNZrib4gDnKfe5vj6zpgVyRa++ReNri2s8cx7GRpghkPoOazzxfsCNAe1rVRAeGA==", + "dependencies": { + "@asyncapi/bundler": "^0.6.4", + "zod": "^4.1.12" + } + }, "lambdas/supplier-allocator/node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -5153,15 +5171,6 @@ "resolved": "internal/events", "link": true }, - "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": { - "version": "1.0.1", - "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-config/1.0.1/ff1ce566201ae291825acd5e771537229d6aa9ca", - "integrity": "sha512-gIZgfzgvkCfZE+HCosrVJ3tBce2FJRGfwPmtYtZDBG+ox/KvbpJFWXzJ5Jkh/42YzcVn2GxT1fy1L1F6pxiYWA==", - "dependencies": { - "@asyncapi/bundler": "^0.6.4", - "zod": "^4.1.12" - } - }, "node_modules/@nhsdigital/notify-supplier-api-consumer-contracts": { "resolved": "pact-contracts", "link": true From 0ac881feebe1faf2e9c69c0c083fb0a5da5bbb62 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 21 Apr 2026 11:46:35 +0100 Subject: [PATCH 16/25] add priority --- config/suppliers/letter-variant/notify-standard-test1.json | 1 + lambdas/supplier-allocator/src/handler/allocate-handler.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/suppliers/letter-variant/notify-standard-test1.json b/config/suppliers/letter-variant/notify-standard-test1.json index dda16237b..be12a1d56 100644 --- a/config/suppliers/letter-variant/notify-standard-test1.json +++ b/config/suppliers/letter-variant/notify-standard-test1.json @@ -24,6 +24,7 @@ "notify-c5", "notify-c4" ], + "priority": 10, "status": "PROD", "type": "STANDARD", "volumeGroupId": "volumeGroup-test1" diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 7445e082c..d60c93430 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -151,7 +151,7 @@ async function getSupplierFromConfig( supplierSpec: { supplierId: selectedSupplierId, specId: preferredPack.id, - priority: 0, + priority: letterVariant.priority, billingId: preferredPack.billingId, }, volumeGroupId: volumeGroup.id, From a5a584427c42d55e6a28836f0178b7c32b811648 Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 22 Apr 2026 10:37:36 +0100 Subject: [PATCH 17/25] unit tests --- internal/datastore/src/__test__/db.ts | 16 + .../supplier-quotas-repository.test.ts | 262 +++++ internal/datastore/src/config.ts | 1 + .../src/supplier-quotas-repository.ts | 167 +-- lambdas/supplier-allocator/jest.config.ts | 2 +- .../__tests__/allocate-handler.test.ts | 99 ++ .../__tests__/allocation-config.test.ts | 1005 +++++++++++++++++ .../src/handler/allocate-handler.ts | 6 - .../__tests__/supplier-config.test.ts | 190 ++-- .../__tests__/supplier-quotas.test.ts | 317 ++++++ 10 files changed, 1913 insertions(+), 152 deletions(-) create mode 100644 internal/datastore/src/__test__/supplier-quotas-repository.test.ts create mode 100644 lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts create mode 100644 lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index de00a6b16..7e5f18edf 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -38,6 +38,7 @@ export async function setupDynamoDBContainer() { letterQueueTtlHours: 1, miTtlHours: 1, supplierConfigTableName: "supplier-config", + supplierQuotasTableName: "supplier-quotas", }; return { @@ -184,6 +185,19 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ ], }); +const createSupplierQuotasTableCommand = new CreateTableCommand({ + TableName: "supplier-quotas", + BillingMode: "PAY_PER_REQUEST", + KeySchema: [ + { AttributeName: "pk", KeyType: "HASH" }, // Partition key + { AttributeName: "sk", KeyType: "RANGE" }, // Sort key + ], + AttributeDefinitions: [ + { AttributeName: "pk", AttributeType: "S" }, + { AttributeName: "sk", AttributeType: "S" }, + ], +}); + export async function createTables(context: DBContext) { const { ddbClient } = context; @@ -194,6 +208,7 @@ export async function createTables(context: DBContext) { await ddbClient.send(createSupplierTableCommand); await ddbClient.send(createLetterQueueTableCommand); await ddbClient.send(createSupplierConfigTableCommand); + await ddbClient.send(createSupplierQuotasTableCommand); } export async function deleteTables(context: DBContext) { @@ -205,6 +220,7 @@ export async function deleteTables(context: DBContext) { "suppliers", "letter-queue", "supplier-config", + "supplier-quotas", ]) { await ddbClient.send( new DeleteTableCommand({ diff --git a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts new file mode 100644 index 000000000..37781433e --- /dev/null +++ b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts @@ -0,0 +1,262 @@ +import { PutCommand } from "@aws-sdk/lib-dynamodb"; +import { + DBContext, + createTables, + deleteTables, + setupDynamoDBContainer, +} from "./db"; +import { SupplierQuotasRepository } from "../supplier-quotas-repository"; + +function createOverallAllocationItem( + allocationId: string, + volumeGroupId: string, + allocations: Record, +) { + return { + pk: "ENTITY#overall-allocation", + sk: `ID#${allocationId}`, + id: allocationId, + volumeGroup: volumeGroupId, + allocations, + updatedAt: new Date().toISOString(), + }; +} + +function createDailyAllocationItem( + allocationId: string, + volumeGroupId: string, + date: string, + allocations: Record, +) { + return { + pk: "ENTITY#daily-allocation", + sk: `ID#${volumeGroupId}#DATE#${date}`, + id: allocationId, + volumeGroup: volumeGroupId, + date, + allocations, + updatedAt: new Date().toISOString(), + }; +} + +jest.setTimeout(30_000); + +describe("SupplierQuotasRepository", () => { + let dbContext: DBContext; + let repository: SupplierQuotasRepository; + + // Database tests can take longer, especially with setup and teardown + beforeAll(async () => { + dbContext = await setupDynamoDBContainer(); + }); + + beforeEach(async () => { + await createTables(dbContext); + repository = new SupplierQuotasRepository(dbContext.docClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + }); + + afterEach(async () => { + await deleteTables(dbContext); + jest.useRealTimers(); + }); + + afterAll(async () => { + await dbContext.container.stop(); + }); + + test("getOverallAllocation returns correct allocation for existing group", async () => { + const volumeGroupId = "group-123"; + const allocations = { supplier1: 100, supplier2: 200 }; + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierQuotasTableName, + Item: createOverallAllocationItem( + volumeGroupId, + volumeGroupId, + allocations, + ), + }), + ); + + const result = await repository.getOverallAllocation(volumeGroupId); + + expect(result).toEqual({ + id: volumeGroupId, + volumeGroup: volumeGroupId, + allocations, + }); + }); + + test("getOverallAllocation returns undefined for non-existent group", async () => { + const volumeGroupId = "non-existent-group"; + + const result = await repository.getOverallAllocation(volumeGroupId); + + expect(result).toBeUndefined(); + }); + + test("putOverallAllocation stores allocation correctly", async () => { + const allocation = { + id: "group-123", + volumeGroup: "group-123", + allocations: { supplier1: 100, supplier2: 200 }, + }; + + await repository.putOverallAllocation(allocation); + + const result = await repository.getOverallAllocation("group-123"); + expect(result).toEqual(allocation); + }); + + test("updateOverallAllocation creates new allocation when none exists", async () => { + const volumeGroupId = "group-123"; + const supplierId = "supplier-123"; + const newAllocation = 50; + + await repository.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + + const result = await repository.getOverallAllocation(volumeGroupId); + expect(result).toEqual({ + id: volumeGroupId, + volumeGroup: volumeGroupId, + allocations: { [supplierId]: newAllocation }, + }); + }); + + test("updateOverallAllocation updates existing allocation", async () => { + const volumeGroupId = "group-123"; + const supplierId = "supplier-123"; + const initialAllocations = { [supplierId]: 100 }; + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierQuotasTableName, + Item: createOverallAllocationItem( + volumeGroupId, + volumeGroupId, + initialAllocations, + ), + }), + ); + + const newAllocation = 50; + await repository.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + + const result = await repository.getOverallAllocation(volumeGroupId); + expect(result?.allocations[supplierId]).toBe(150); + }); + + test("getDailyAllocation returns correct allocation for existing group and date", async () => { + const allocationId = "daily-allocation-123"; + const volumeGroupId = "group-123"; + const date = "2023-10-01"; + const allocations = { supplier1: 50, supplier2: 75 }; + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierQuotasTableName, + Item: createDailyAllocationItem( + allocationId, + volumeGroupId, + date, + allocations, + ), + }), + ); + + const result = await repository.getDailyAllocation(volumeGroupId, date); + + expect(result).toEqual({ + id: allocationId, + volumeGroup: volumeGroupId, + date, + allocations, + }); + }); + + test("getDailyAllocation returns undefined for non-existent group and date", async () => { + const volumeGroupId = "non-existent-group"; + const date = "2023-10-01"; + + const result = await repository.getDailyAllocation(volumeGroupId, date); + + expect(result).toBeUndefined(); + }); + + test("putDailyAllocation stores allocation correctly", async () => { + const allocation = { + id: "daily-allocation-123", + volumeGroup: "group-123", + date: "2023-10-01", + allocations: { supplier1: 50, supplier2: 75 }, + }; + + await repository.putDailyAllocation(allocation); + + const result = await repository.getDailyAllocation( + "group-123", + "2023-10-01", + ); + expect(result).toEqual(allocation); + }); + + test("updateDailyAllocation creates new allocation when none exists", async () => { + const volumeGroupId = "group-123"; + const date = "2023-10-01"; + const supplierId = "supplier-123"; + const newAllocation = 25; + + await repository.updateDailyAllocation( + volumeGroupId, + date, + supplierId, + newAllocation, + ); + + const result = await repository.getDailyAllocation(volumeGroupId, date); + expect(result).toEqual({ + id: `${volumeGroupId}#DATE#${date}`, + volumeGroup: volumeGroupId, + date, + allocations: { [supplierId]: newAllocation }, + }); + }); + + test("updateDailyAllocation updates existing allocation", async () => { + const allocationId = "daily-allocation-123"; + const volumeGroupId = "group-123"; + const date = "2023-10-01"; + const supplierId = "supplier-123"; + const initialAllocations = { [supplierId]: 50 }; + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierQuotasTableName, + Item: createDailyAllocationItem( + allocationId, + volumeGroupId, + date, + initialAllocations, + ), + }), + ); + + const newAllocation = 25; + await repository.updateDailyAllocation( + volumeGroupId, + date, + supplierId, + newAllocation, + ); + + const result = await repository.getDailyAllocation(volumeGroupId, date); + expect(result?.allocations[supplierId]).toBe(75); + }); +}); diff --git a/internal/datastore/src/config.ts b/internal/datastore/src/config.ts index f07954028..ed18b54fa 100644 --- a/internal/datastore/src/config.ts +++ b/internal/datastore/src/config.ts @@ -9,4 +9,5 @@ export type DatastoreConfig = { letterQueueTtlHours: number; miTtlHours: number; supplierConfigTableName: string; + supplierQuotasTableName: string; }; diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index 48b6faefb..195d8d765 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -45,30 +45,24 @@ export class SupplierQuotasRepository { if (!result.Item) { return undefined; } - return $OverallAllocation.parse(result.Item); + // Strip DynamoDB keys before parsing + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { pk, sk, ...item } = result.Item; + return $OverallAllocation.parse(item); } async putOverallAllocation(allocation: OverallAllocation): Promise { - try { - const parsedAllocation = $OverallAllocation.parse(allocation); - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "overall-allocation", - allocation.id, - parsedAllocation, - ), - }), - ); - } catch (error) { - if (error instanceof Error) { - throw new Error( - `Failed to put overall allocation for id ${allocation.id}: ${error.message}`, - ); - } - throw error; - } + const parsedAllocation = $OverallAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "overall-allocation", + allocation.id, + parsedAllocation, + ), + }), + ); } // Update the overallAllocation table updating the allocations array for a given volume group @@ -80,26 +74,36 @@ export class SupplierQuotasRepository { ): Promise { const overallAllocation = await this.getOverallAllocation(groupId); const allocations = overallAllocation?.allocations ?? {}; - const currentAllocation = Object.hasOwn(allocations, supplierId) - ? allocations[supplierId] - : 0; + const currentAllocation = allocations[supplierId] ?? 0; const updatedAllocation = currentAllocation + newAllocation; - await this.ddbClient.send( - new UpdateCommand({ - TableName: this.config.supplierQuotasTableName, - Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, - UpdateExpression: - "SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt", - ExpressionAttributeNames: { - "#supplierId": supplierId, - }, - ExpressionAttributeValues: { - ":updatedAllocation": updatedAllocation, - ":updatedAt": new Date().toISOString(), - }, - }), - ); + if (overallAllocation) { + // Update existing allocation + const updatedAllocations = { + ...allocations, + [supplierId]: updatedAllocation, + }; + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, + UpdateExpression: + "SET allocations = :allocations, updatedAt = :updatedAt", + ExpressionAttributeValues: { + ":allocations": updatedAllocations, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } else { + // Create new allocation + const newOverallAllocation: OverallAllocation = { + id: groupId, + volumeGroup: groupId, + allocations: { [supplierId]: updatedAllocation }, + }; + await this.putOverallAllocation(newOverallAllocation); + } } async getDailyAllocation( @@ -118,30 +122,24 @@ export class SupplierQuotasRepository { if (!result.Item) { return undefined; } - return $DailyAllocation.parse(result.Item); + // Strip DynamoDB keys before parsing + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { pk, sk, ...item } = result.Item; + return $DailyAllocation.parse(item); } async putDailyAllocation(allocation: DailyAllocation): Promise { - try { - const parsedAllocation = $DailyAllocation.parse(allocation); - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "daily-allocation", - `${allocation.volumeGroup}#DATE#${allocation.date}`, - parsedAllocation, - ), - }), - ); - } catch (error) { - if (error instanceof Error) { - throw new Error( - `Failed to put daily allocation for volume group ${allocation.volumeGroup} and date ${allocation.date}: ${error.message}`, - ); - } - throw error; - } + const parsedAllocation = $DailyAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "daily-allocation", + `${allocation.volumeGroup}#DATE#${allocation.date}`, + parsedAllocation, + ), + }), + ); } async updateDailyAllocation( @@ -155,23 +153,36 @@ export class SupplierQuotasRepository { const currentAllocation = allocations[supplierId] ?? 0; const updatedAllocation = currentAllocation + newAllocation; - await this.ddbClient.send( - new UpdateCommand({ - TableName: this.config.supplierQuotasTableName, - Key: { - pk: "ENTITY#daily-allocation", - sk: `ID#${groupId}#DATE#${date}`, - }, - UpdateExpression: - "SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt", - ExpressionAttributeNames: { - "#supplierId": supplierId, - }, - ExpressionAttributeValues: { - ":updatedAllocation": updatedAllocation, - ":updatedAt": new Date().toISOString(), - }, - }), - ); + if (dailyAllocation) { + // Update existing allocation + const updatedAllocations = { + ...allocations, + [supplierId]: updatedAllocation, + }; + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${groupId}#DATE#${date}`, + }, + UpdateExpression: + "SET allocations = :allocations, updatedAt = :updatedAt", + ExpressionAttributeValues: { + ":allocations": updatedAllocations, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } else { + // Create new allocation + const newDailyAllocation: DailyAllocation = { + id: `${groupId}#DATE#${date}`, + date, + volumeGroup: groupId, + allocations: { [supplierId]: updatedAllocation }, + }; + await this.putDailyAllocation(newDailyAllocation); + } } } diff --git a/lambdas/supplier-allocator/jest.config.ts b/lambdas/supplier-allocator/jest.config.ts index 9f16a04f2..872794514 100644 --- a/lambdas/supplier-allocator/jest.config.ts +++ b/lambdas/supplier-allocator/jest.config.ts @@ -14,7 +14,7 @@ export const baseJestConfig = { clearMocks: true, // Indicates whether the coverage information should be collected while executing the test - collectCoverage: false, + collectCoverage: true, // The directory where Jest should output its coverage files coverageDirectory: "./.reports/unit/coverage", diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index a32259b53..5408faac6 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -584,4 +584,103 @@ describe("createSupplierAllocatorHandler", () => { }), ); }); + + const rejectWith = (mock: jest.Mock, error: Error) => + mock.mockRejectedValueOnce(error); + + const supplierConfigErrorCases = [ + { + name: "getVolumeGroupDetails", + setup: () => + rejectWith( + supplierConfig.getVolumeGroupDetails as jest.Mock, + new Error("Volume group retrieval failed"), + ), + }, + { + name: "eligibleSuppliers", + setup: () => + rejectWith( + allocationConfig.eligibleSuppliers as jest.Mock, + new Error("Eligible suppliers retrieval failed"), + ), + }, + { + name: "preferredSupplierPack", + setup: () => + rejectWith( + allocationConfig.preferredSupplierPack as jest.Mock, + new Error("Preferred supplier pack retrieval failed"), + ), + }, + { + name: "suppliersWithValidPack", + setup: () => + rejectWith( + allocationConfig.suppliersWithValidPack as jest.Mock, + new Error("Suppliers with valid pack retrieval failed"), + ), + }, + { + name: "filterSuppliersWithCapacity", + setup: () => + rejectWith( + allocationConfig.filterSuppliersWithCapacity as jest.Mock, + new Error("Filter suppliers with capacity failed"), + ), + }, + { + name: "selectSupplierByFactor", + setup: () => + rejectWith( + allocationConfig.selectSupplierByFactor as jest.Mock, + new Error("Select supplier by factor failed"), + ), + }, + ]; + + test.each(supplierConfigErrorCases)( + "logs error when %s rejects during supplier config resolution", + async ({ setup }) => { + const preparedEvent = createPreparedV2Event(); + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg1", JSON.stringify(preparedEvent)), + ]); + + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + setup(); + + const handler = createSupplierAllocatorHandler(mockedDeps); + const result = await handler(evt, {} as any, {} as any); + if (!result) throw new Error("expected BatchResponse, got void"); + + expect(result.batchItemFailures).toHaveLength(0); + expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( + expect.objectContaining({ + description: "Error fetching supplier from config", + variantId: "lv1", + }), + ); + }, + ); + + test("falls back to the second selectSupplierByFactor call when the first returns undefined", async () => { + setupDefaultMocks(); + (allocationConfig.selectSupplierByFactor as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("supplier1"); + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg1", JSON.stringify(createPreparedV2Event())), + ]); + + const handler = createSupplierAllocatorHandler(mockedDeps); + const result = await handler(evt, {} as any, {} as any); + if (!result) throw new Error("expected BatchResponse, got void"); + + expect(result.batchItemFailures).toHaveLength(0); + expect(allocationConfig.selectSupplierByFactor).toHaveBeenCalledTimes(2); + }); }); diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts new file mode 100644 index 000000000..77e8a5c66 --- /dev/null +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts @@ -0,0 +1,1005 @@ +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; + +import { + PackSpecification, + Supplier, + SupplierAllocation, + SupplierPack, + VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { Deps } from "../../config/deps"; +import { + eligibleSuppliers, + filterSuppliersWithCapacity, + preferredSupplierPack, + selectSupplierByFactor, + suppliersWithValidPack, +} from "../allocation-config"; +import * as supplierConfigService from "../../services/supplier-config"; +import * as supplierQuotasService from "../../services/supplier-quotas"; + +jest.mock("../../services/supplier-config"); +jest.mock("../../services/supplier-quotas"); + +describe("eligibleSuppliers", () => { + let mockDeps: jest.Mocked; + let mockVolumeGroup: VolumeGroup; + let mockSupplierAllocations: SupplierAllocation[]; + let mockSuppliers: Supplier[]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockVolumeGroup = { + id: "volume-group-1", + name: "Test Volume Group", + } as VolumeGroup; + + mockSupplierAllocations = [ + { + id: "allocation-1", + volumeGroup: "volume-group-1", + supplier: "supplier-1", + allocationPercentage: 50, + status: "PROD", + } as SupplierAllocation, + { + id: "allocation-2", + volumeGroup: "volume-group-1", + supplier: "supplier-2", + allocationPercentage: 30, + status: "PROD", + } as SupplierAllocation, + ]; + + mockSuppliers = [ + { + id: "supplier-1", + name: "Supplier One", + dailyCapacity: 1000, + } as Supplier, + { + id: "supplier-2", + name: "Supplier Two", + dailyCapacity: 500, + } as Supplier, + ]; + + mockDeps = { + logger: { info: jest.fn(), error: jest.fn() }, + } as unknown as jest.Mocked; + }); + + it("should return supplier allocations and suppliers when successful", async () => { + ( + supplierConfigService.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockResolvedValue(mockSupplierAllocations); + (supplierConfigService.getSupplierDetails as jest.Mock).mockResolvedValue( + mockSuppliers, + ); + + const result = await eligibleSuppliers(mockVolumeGroup, mockDeps); + + expect(result.supplierAllocations).toEqual(mockSupplierAllocations); + expect(result.suppliers).toEqual(mockSuppliers); + }); + + it("should call getSupplierAllocationsForVolumeGroup with correct volume group id", async () => { + ( + supplierConfigService.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockResolvedValue(mockSupplierAllocations); + (supplierConfigService.getSupplierDetails as jest.Mock).mockResolvedValue( + mockSuppliers, + ); + + await eligibleSuppliers(mockVolumeGroup, mockDeps); + + expect( + supplierConfigService.getSupplierAllocationsForVolumeGroup, + ).toHaveBeenCalledWith("volume-group-1", mockDeps); + }); + + it("should extract supplier ids from allocations and call getSupplierDetails", async () => { + ( + supplierConfigService.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockResolvedValue(mockSupplierAllocations); + (supplierConfigService.getSupplierDetails as jest.Mock).mockResolvedValue( + mockSuppliers, + ); + + await eligibleSuppliers(mockVolumeGroup, mockDeps); + + expect(supplierConfigService.getSupplierDetails).toHaveBeenCalledWith( + ["supplier-1", "supplier-2"], + mockDeps, + ); + }); + + it("should handle empty supplier allocations", async () => { + ( + supplierConfigService.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockResolvedValue([]); + (supplierConfigService.getSupplierDetails as jest.Mock).mockResolvedValue( + [], + ); + + const result = await eligibleSuppliers(mockVolumeGroup, mockDeps); + + expect(result.supplierAllocations).toEqual([]); + expect(result.suppliers).toEqual([]); + expect(supplierConfigService.getSupplierDetails).toHaveBeenCalledWith( + [], + mockDeps, + ); + }); + + it("should propagate errors from getSupplierAllocationsForVolumeGroup", async () => { + const error = new Error("Database error"); + ( + supplierConfigService.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockRejectedValue(error); + + await expect(eligibleSuppliers(mockVolumeGroup, mockDeps)).rejects.toThrow( + "Database error", + ); + }); + + it("should propagate errors from getSupplierDetails", async () => { + ( + supplierConfigService.getSupplierAllocationsForVolumeGroup as jest.Mock + ).mockResolvedValue(mockSupplierAllocations); + const error = new Error("Supplier service error"); + (supplierConfigService.getSupplierDetails as jest.Mock).mockRejectedValue( + error, + ); + + await expect(eligibleSuppliers(mockVolumeGroup, mockDeps)).rejects.toThrow( + "Supplier service error", + ); + }); +}); + +describe("preferredSupplierPack", () => { + let mockLetterEvent: LetterRequestPreparedEventV2; + let mockPackSpecificationIds: string[]; + let mockDeps: jest.Mocked; + let mockSuppliers: Supplier[]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockLetterEvent = { + letterid: "letter-1", + specification: "spec-1", + } as unknown as LetterRequestPreparedEventV2; + + mockSuppliers = [ + { + id: "supplier-1", + name: "Supplier One", + dailyCapacity: 1000, + } as Supplier, + ]; + + mockPackSpecificationIds = ["pack-spec-1", "pack-spec-2"]; + + mockDeps = { + logger: { info: jest.fn(), error: jest.fn() }, + } as unknown as jest.Mocked; + }); + + it("should return preferred pack specification when successful", async () => { + const mockEligiblePacks = ["pack-spec-1"]; + const mockPreferredSupplierPacks = [ + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-1", + } as SupplierPack, + ]; + const mockPackSpecification = { + id: "pack-spec-1", + name: "Preferred Pack", + } as PackSpecification; + + (supplierConfigService.filterPacksForLetter as jest.Mock).mockResolvedValue( + mockEligiblePacks, + ); + ( + supplierConfigService.getPreferredSupplierPacks as jest.Mock + ).mockResolvedValue(mockPreferredSupplierPacks); + (supplierConfigService.getPackSpecification as jest.Mock).mockResolvedValue( + mockPackSpecification, + ); + + const result = await preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ); + + expect(result).toEqual(mockPackSpecification); + }); + + it("should call filterPacksForLetter with correct parameters", async () => { + (supplierConfigService.filterPacksForLetter as jest.Mock).mockResolvedValue( + ["pack-spec-1"], + ); + ( + supplierConfigService.getPreferredSupplierPacks as jest.Mock + ).mockResolvedValue([ + { packSpecificationId: "pack-spec-1", supplierId: "supplier-1" }, + ]); + (supplierConfigService.getPackSpecification as jest.Mock).mockResolvedValue( + { id: "pack-spec-1", name: "Pack" }, + ); + + await preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ); + + expect(supplierConfigService.filterPacksForLetter).toHaveBeenCalledWith( + mockLetterEvent, + mockPackSpecificationIds, + mockDeps, + ); + }); + + it("should call getPreferredSupplierPacks with eligible packs and suppliers", async () => { + const mockEligiblePacks = ["pack-spec-1", "pack-spec-2"]; + (supplierConfigService.filterPacksForLetter as jest.Mock).mockResolvedValue( + mockEligiblePacks, + ); + ( + supplierConfigService.getPreferredSupplierPacks as jest.Mock + ).mockResolvedValue([ + { packSpecificationId: "pack-spec-1", supplierId: "supplier-1" }, + ]); + (supplierConfigService.getPackSpecification as jest.Mock).mockResolvedValue( + { id: "pack-spec-1", name: "Pack" }, + ); + + await preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ); + + expect( + supplierConfigService.getPreferredSupplierPacks, + ).toHaveBeenCalledWith(mockEligiblePacks, mockSuppliers, mockDeps); + }); + + it("should call getPackSpecification with the first preferred pack id", async () => { + (supplierConfigService.filterPacksForLetter as jest.Mock).mockResolvedValue( + ["pack-spec-1"], + ); + ( + supplierConfigService.getPreferredSupplierPacks as jest.Mock + ).mockResolvedValue([ + { packSpecificationId: "pack-spec-1", supplierId: "supplier-1" }, + ]); + (supplierConfigService.getPackSpecification as jest.Mock).mockResolvedValue( + { id: "pack-spec-1", name: "Pack" }, + ); + + await preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ); + + expect(supplierConfigService.getPackSpecification).toHaveBeenCalledWith( + "pack-spec-1", + mockDeps, + ); + }); + + it("should propagate errors from filterPacksForLetter", async () => { + const error = new Error("Filter error"); + (supplierConfigService.filterPacksForLetter as jest.Mock).mockRejectedValue( + error, + ); + + await expect( + preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ), + ).rejects.toThrow("Filter error"); + }); + + it("should propagate errors from getPreferredSupplierPacks", async () => { + (supplierConfigService.filterPacksForLetter as jest.Mock).mockResolvedValue( + ["pack-spec-1"], + ); + const error = new Error("Preference error"); + ( + supplierConfigService.getPreferredSupplierPacks as jest.Mock + ).mockRejectedValue(error); + + await expect( + preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ), + ).rejects.toThrow("Preference error"); + }); + + it("should propagate errors from getPackSpecification", async () => { + (supplierConfigService.filterPacksForLetter as jest.Mock).mockResolvedValue( + ["pack-spec-1"], + ); + ( + supplierConfigService.getPreferredSupplierPacks as jest.Mock + ).mockResolvedValue([ + { packSpecificationId: "pack-spec-1", supplierId: "supplier-1" }, + ]); + const error = new Error("Pack specification error"); + (supplierConfigService.getPackSpecification as jest.Mock).mockRejectedValue( + error, + ); + + await expect( + preferredSupplierPack( + mockLetterEvent, + mockSuppliers, + mockPackSpecificationIds, + mockDeps, + ), + ).rejects.toThrow("Pack specification error"); + }); +}); + +describe("suppliersWithValidPack", () => { + let mockPackSpecificationId: string; + let mockDeps: jest.Mocked; + let mockSuppliers: Supplier[]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSuppliers = [ + { + id: "supplier-1", + name: "Supplier One", + dailyCapacity: 1000, + } as Supplier, + { + id: "supplier-2", + name: "Supplier Two", + dailyCapacity: 500, + } as Supplier, + { + id: "supplier-3", + name: "Supplier Three", + dailyCapacity: 750, + } as Supplier, + ]; + + mockPackSpecificationId = "pack-spec-1"; + + mockDeps = { + logger: { info: jest.fn(), error: jest.fn() }, + } as unknown as jest.Mocked; + }); + + it("should return suppliers that have valid packs for the specification", async () => { + const mockSupplierPacks = [ + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-1", + } as SupplierPack, + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-3", + } as SupplierPack, + ]; + + (supplierConfigService.getSupplierPacks as jest.Mock).mockResolvedValue( + mockSupplierPacks, + ); + + const result = await suppliersWithValidPack( + mockSuppliers, + mockPackSpecificationId, + mockDeps, + ); + + expect(result).toEqual([mockSuppliers[0], mockSuppliers[2]]); + }); + + it("should call getSupplierPacks with correct parameters", async () => { + (supplierConfigService.getSupplierPacks as jest.Mock).mockResolvedValue([]); + + await suppliersWithValidPack( + mockSuppliers, + mockPackSpecificationId, + mockDeps, + ); + + expect(supplierConfigService.getSupplierPacks).toHaveBeenCalledWith( + mockPackSpecificationId, + mockDeps, + ); + }); + + it("should return empty array when no suppliers have valid packs", async () => { + (supplierConfigService.getSupplierPacks as jest.Mock).mockResolvedValue([]); + + const result = await suppliersWithValidPack( + mockSuppliers, + mockPackSpecificationId, + mockDeps, + ); + + expect(result).toEqual([]); + }); + + it("should return all suppliers when all have valid packs", async () => { + const mockSupplierPacks = [ + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-1", + } as SupplierPack, + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-2", + } as SupplierPack, + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-3", + } as SupplierPack, + ]; + + (supplierConfigService.getSupplierPacks as jest.Mock).mockResolvedValue( + mockSupplierPacks, + ); + + const result = await suppliersWithValidPack( + mockSuppliers, + mockPackSpecificationId, + mockDeps, + ); + + expect(result).toEqual(mockSuppliers); + }); + + it("should handle empty suppliers array", async () => { + const mockSupplierPacks = [ + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-1", + } as SupplierPack, + ]; + + (supplierConfigService.getSupplierPacks as jest.Mock).mockResolvedValue( + mockSupplierPacks, + ); + + const result = await suppliersWithValidPack( + [], + mockPackSpecificationId, + mockDeps, + ); + + expect(result).toEqual([]); + }); + + it("should propagate errors from getSupplierPacks", async () => { + const error = new Error("Supplier packs service error"); + (supplierConfigService.getSupplierPacks as jest.Mock).mockRejectedValue( + error, + ); + + await expect( + suppliersWithValidPack(mockSuppliers, mockPackSpecificationId, mockDeps), + ).rejects.toThrow("Supplier packs service error"); + }); + + it("should filter suppliers correctly when pack specification has multiple supplier packs", async () => { + const mockSupplierPacks = [ + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-1", + } as SupplierPack, + { + packSpecificationId: "pack-spec-1", + supplierId: "supplier-2", + } as SupplierPack, + ]; + + (supplierConfigService.getSupplierPacks as jest.Mock).mockResolvedValue( + mockSupplierPacks, + ); + + const result = await suppliersWithValidPack( + mockSuppliers, + mockPackSpecificationId, + mockDeps, + ); + + expect(result).toHaveLength(2); + expect(result).toEqual([mockSuppliers[0], mockSuppliers[1]]); + }); +}); + +describe("filterSuppliersWithCapacity", () => { + let mockDeps: jest.Mocked; + let mockSuppliers: Supplier[]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSuppliers = [ + { + id: "supplier-1", + name: "Supplier One", + dailyCapacity: 1000, + } as Supplier, + { + id: "supplier-2", + name: "Supplier Two", + dailyCapacity: 500, + } as Supplier, + { + id: "supplier-3", + name: "Supplier Three", + dailyCapacity: 750, + } as Supplier, + ]; + + mockDeps = { + logger: { info: jest.fn(), error: jest.fn() }, + supplierQuotasRepo: { + getDailyAllocation: jest.fn(), + }, + } as unknown as jest.Mocked; + }); + + it("should return suppliers with available capacity", async () => { + const mockDailyAllocation = { + allocations: { + "supplier-1": 500, + "supplier-2": 600, + "supplier-3": 400, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + + const result = await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual([mockSuppliers[0], mockSuppliers[2]]); + }); + + it("should call getDailyAllocation with correct parameters", async () => { + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(mockDeps.supplierQuotasRepo.getDailyAllocation).toHaveBeenCalledWith( + "volume-group-1", + expect.any(String), + ); + }); + + it("should return all suppliers when no daily allocation exists", async () => { + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + const result = await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual(mockSuppliers); + }); + + it("should handle suppliers with zero allocation", async () => { + const mockDailyAllocation = { + allocations: { + "supplier-1": 0, + "supplier-2": 450, + "supplier-3": 700, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + + const result = await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual([ + mockSuppliers[0], + mockSuppliers[1], + mockSuppliers[2], + ]); + }); + + it("should exclude suppliers at full capacity", async () => { + const mockDailyAllocation = { + allocations: { + "supplier-1": 999, + "supplier-2": 499, + "supplier-3": 750, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + console.log( + "Testing filterSuppliersWithCapacity with mockDailyAllocation:", + mockDailyAllocation, + ); + const result = await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual([mockSuppliers[0], mockSuppliers[1]]); + }); + + it("should handle missing allocation entries for suppliers", async () => { + const mockDailyAllocation = { + allocations: { + "supplier-1": 500, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + + const result = await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual(mockSuppliers); + }); + + it("should handle empty suppliers array", async () => { + const mockDailyAllocation = { + allocations: { + "supplier-1": 500, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + + const result = await filterSuppliersWithCapacity( + [], + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual([]); + }); + + it("should propagate errors from getDailyAllocation", async () => { + const error = new Error("Quotas service error"); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockRejectedValue(error); + + await expect( + filterSuppliersWithCapacity(mockSuppliers, "volume-group-1", mockDeps), + ).rejects.toThrow("Quotas service error"); + }); + + it("should use current date in YYYY-MM-DD format for daily allocation query", async () => { + const mockDailyAllocation = { + allocations: {}, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + + await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + const callArgs = ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mock.calls[0]; + const dateArg = callArgs[1]; + + expect(dateArg).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("should return suppliers where allocated capacity is less than daily capacity", async () => { + const mockDailyAllocation = { + allocations: { + "supplier-1": 999, + "supplier-2": 1, + "supplier-3": 749, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(mockDailyAllocation); + + const result = await filterSuppliersWithCapacity( + mockSuppliers, + "volume-group-1", + mockDeps, + ); + + expect(result).toEqual([ + mockSuppliers[0], + mockSuppliers[1], + mockSuppliers[2], + ]); + }); +}); +describe("selectSupplierByFactor", () => { + let mockDeps: jest.Mocked; + let mockSuppliers: Supplier[]; + let mockSupplierAllocations: SupplierAllocation[]; + + beforeEach(() => { + jest.clearAllMocks(); + + mockSuppliers = [ + { + id: "supplier-1", + name: "Supplier One", + dailyCapacity: 1000, + } as Supplier, + { + id: "supplier-2", + name: "Supplier Two", + dailyCapacity: 500, + } as Supplier, + { + id: "supplier-3", + name: "Supplier Three", + dailyCapacity: 750, + } as Supplier, + ]; + + mockSupplierAllocations = [ + { + id: "allocation-1", + volumeGroup: "volume-group-1", + supplier: "supplier-1", + allocationPercentage: 50, + status: "PROD", + } as SupplierAllocation, + { + id: "allocation-2", + volumeGroup: "volume-group-1", + supplier: "supplier-2", + allocationPercentage: 30, + status: "PROD", + } as SupplierAllocation, + { + id: "allocation-3", + volumeGroup: "volume-group-1", + supplier: "supplier-3", + allocationPercentage: 20, + status: "PROD", + } as SupplierAllocation, + ]; + + mockDeps = { + logger: { info: jest.fn(), error: jest.fn() }, + } as unknown as jest.Mocked; + }); + + it("should return supplier with lowest factor", async () => { + const mockSupplierFactors = [ + { supplierId: "supplier-1", factor: 0.5 }, + { supplierId: "supplier-2", factor: 0.2 }, + { supplierId: "supplier-3", factor: 0.8 }, + ]; + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + mockSuppliers, + mockSupplierAllocations, + mockDeps, + ); + + expect(result).toBe("supplier-2"); + }); + + it("should return first supplier when all factors are equal", async () => { + const mockSupplierFactors = [ + { supplierId: "supplier-1", factor: 0.5 }, + { supplierId: "supplier-2", factor: 0.5 }, + { supplierId: "supplier-3", factor: 0.5 }, + ]; + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + mockSuppliers, + mockSupplierAllocations, + mockDeps, + ); + + expect(result).toBe("supplier-1"); + }); + + it("should handle single supplier", async () => { + const singleSupplier = [mockSuppliers[0]]; + const singleAllocation = [mockSupplierAllocations[0]]; + + const mockSupplierFactors = [{ supplierId: "supplier-1", factor: 0.5 }]; + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + singleSupplier, + singleAllocation, + mockDeps, + ); + + expect(result).toBe("supplier-1"); + }); + + it("should select supplier with zero factor", async () => { + const mockSupplierFactors = [ + { supplierId: "supplier-1", factor: 0.5 }, + { supplierId: "supplier-2", factor: 0 }, + { supplierId: "supplier-3", factor: 0.3 }, + ]; + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + mockSuppliers, + mockSupplierAllocations, + mockDeps, + ); + + expect(result).toBe("supplier-2"); + }); + + it("should handle negative factors", async () => { + const mockSupplierFactors = [ + { supplierId: "supplier-1", factor: 0.5 }, + { supplierId: "supplier-2", factor: -0.1 }, + { supplierId: "supplier-3", factor: 0.2 }, + ]; + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + mockSuppliers, + mockSupplierAllocations, + mockDeps, + ); + + expect(result).toBe("supplier-2"); + }); + + it("should propagate errors from calculateSupplierAllocatedFactor", async () => { + const error = new Error("Factor calculation error"); + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockRejectedValue(error); + + await expect( + selectSupplierByFactor(mockSuppliers, mockSupplierAllocations, mockDeps), + ).rejects.toThrow("Factor calculation error"); + }); + + it("should exclude suppliers not in the suppliers list", async () => { + const allocationsWithUnrelatedSupplier = [ + mockSupplierAllocations[0], + { + id: "allocation-extra", + volumeGroup: "volume-group-1", + supplier: "supplier-unknown", + allocationPercentage: 5, + status: "PROD", + } as SupplierAllocation, + ]; + + const mockSupplierFactors = [{ supplierId: "supplier-1", factor: 0.5 }]; + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + mockSuppliers, + allocationsWithUnrelatedSupplier, + mockDeps, + ); + + expect(result).toBe("supplier-1"); + }); + + it("should correctly identify lowest factor among many suppliers", async () => { + const manySuppliers = Array.from( + { length: 10 }, + (_, i) => + ({ + id: `supplier-${i}`, + name: `Supplier ${i}`, + dailyCapacity: 1000, + }) as Supplier, + ); + + const manyAllocations = Array.from( + { length: 10 }, + (_, i) => + ({ + id: `allocation-${i}`, + volumeGroup: "volume-group-1", + supplier: `supplier-${i}`, + allocationPercentage: 10, + status: "PROD", + }) as SupplierAllocation, + ); + + const mockSupplierFactors = Array.from({ length: 10 }, (_, i) => ({ + supplierId: `supplier-${i}`, + factor: i === 5 ? 0.1 : 0.5 + i * 0.01, + })); + + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue(mockSupplierFactors); + + const result = await selectSupplierByFactor( + manySuppliers, + manyAllocations, + mockDeps, + ); + + expect(result).toBe("supplier-5"); + }); +}); diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index d60c93430..c9d594236 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -128,12 +128,6 @@ async function getSupplierFromConfig( deps, )); - if (!selectedSupplierId) { - throw new Error( - "No suppliers found with capacity or valid allocation factor for preferred pack", - ); - } - deps.logger.info({ description: "Fetched supplier details for supplier allocations", variantId: letterEvent.data.letterVariantId, diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index 4e6f4cd4f..19ff804fc 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -4,6 +4,7 @@ import { getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, + getSupplierPacks, getVariantDetails, getVolumeGroupDetails, } from "../supplier-config"; @@ -242,82 +243,85 @@ describe("supplier-config service", () => { "s5", ]); }); - }); - it("logs a warning when supplier allocations count differs from supplier details count", async () => { - const supplierIds = ["s1", "s2", "s3"]; - const suppliers = [ - { id: "s1", name: "Supplier 1", status: "PROD" }, - { id: "s2", name: "Supplier 2", status: "PROD" }, - ] as any[]; - const deps = makeDeps(); - deps.supplierConfigRepo.getSuppliersDetails = jest - .fn() - .mockResolvedValue(suppliers); - - await getSupplierDetails(supplierIds, deps); - - expect(deps.logger.warn).toHaveBeenCalledWith({ - description: "Mismatch between supplier allocations and supplier details", - allocationsCount: 3, - detailsCount: 2, - missingSuppliers: ["s3"], + + it("logs a warning when supplier allocations count differs from supplier details count", async () => { + const supplierIds = ["s1", "s2", "s3"]; + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); + + await getSupplierDetails(supplierIds, deps); + + expect(deps.logger.warn).toHaveBeenCalledWith({ + description: + "Mismatch between supplier allocations and supplier details", + allocationsCount: 3, + detailsCount: 2, + missingSuppliers: ["s3"], + }); }); - }); - it("does not log a warning when counts match", async () => { - const supplierIds = ["s1", "s2"]; - const suppliers = [ - { id: "s1", name: "Supplier 1", status: "PROD" }, - { id: "s2", name: "Supplier 2", status: "PROD" }, - ] as any[]; - const deps = makeDeps(); - deps.supplierConfigRepo.getSuppliersDetails = jest - .fn() - .mockResolvedValue(suppliers); + it("does not log a warning when counts match", async () => { + const supplierIds = ["s1", "s2"]; + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); - await getSupplierDetails(supplierIds, deps); + await getSupplierDetails(supplierIds, deps); - expect(deps.logger.warn).not.toHaveBeenCalled(); - }); + expect(deps.logger.warn).not.toHaveBeenCalled(); + }); - it("throws when no active suppliers found", async () => { - const supplierIds = ["s1", "s2"]; - const suppliers = [ - { id: "s1", name: "Supplier 1", status: "DRAFT" }, - { id: "s2", name: "Supplier 2", status: "DRAFT" }, - ] as any[]; - const deps = makeDeps(); - deps.supplierConfigRepo.getSuppliersDetails = jest - .fn() - .mockResolvedValue(suppliers); - - await expect(getSupplierDetails(supplierIds, deps)).rejects.toThrow( - /No active suppliers found/, - ); - expect(deps.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: "No active suppliers found for supplier allocations", - }), - ); - }); + it("throws when no active suppliers found", async () => { + const supplierIds = ["s1", "s2"]; + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "DRAFT" }, + { id: "s2", name: "Supplier 2", status: "DRAFT" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); - it("filters to return only active suppliers with PROD status", async () => { - const supplierIds = ["s1", "s2", "s3"]; - const suppliers = [ - { id: "s1", name: "Supplier 1", status: "PROD" }, - { id: "s2", name: "Supplier 2", status: "DRAFT" }, - { id: "s3", name: "Supplier 3", status: "PROD" }, - ] as any[]; - const deps = makeDeps(); - deps.supplierConfigRepo.getSuppliersDetails = jest - .fn() - .mockResolvedValue(suppliers); + await expect(getSupplierDetails(supplierIds, deps)).rejects.toThrow( + /No active suppliers found/, + ); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: "No active suppliers found for supplier allocations", + }), + ); + }); - const result = await getSupplierDetails(supplierIds, deps); + it("filters to return only active suppliers with PROD status", async () => { + const supplierIds = ["s1", "s2", "s3"]; + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "DRAFT" }, + { id: "s3", name: "Supplier 3", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSuppliersDetails = jest + .fn() + .mockResolvedValue(suppliers); - expect(result).toEqual([suppliers[0], suppliers[2]]); - expect(result.every((s) => s.status === "PROD")).toBe(true); + const result = await getSupplierDetails(supplierIds, deps); + + expect(result).toEqual([suppliers[0], suppliers[2]]); + expect(result.every((s) => s.status === "PROD")).toBe(true); + }); }); + describe("getPreferredSupplierPacks", () => { it("returns preferred supplier packs when found", async () => { const suppliers = [ @@ -582,4 +586,56 @@ describe("supplier-config service", () => { expect(result).toEqual(["spec1"]); }); }); + + describe("getSupplierPacks", () => { + it("returns supplier packs for a valid pack specification id", async () => { + const packSpecificationId = "spec1"; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + const result = await getSupplierPacks(packSpecificationId, deps); + + expect(result).toEqual(supplierPacks); + expect( + deps.supplierConfigRepo.getSupplierPacksForPackSpecification, + ).toHaveBeenCalledWith(packSpecificationId); + }); + + it("returns empty array when no supplier packs found", async () => { + const packSpecificationId = "spec-nonexistent"; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue([]); + + const result = await getSupplierPacks(packSpecificationId, deps); + + expect(result).toEqual([]); + expect( + deps.supplierConfigRepo.getSupplierPacksForPackSpecification, + ).toHaveBeenCalledWith(packSpecificationId); + }); + + it("returns single supplier pack", async () => { + const packSpecificationId = "spec1"; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + const result = await getSupplierPacks(packSpecificationId, deps); + + expect(result).toHaveLength(1); + expect(result).toEqual(supplierPacks); + }); + }); }); diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts new file mode 100644 index 000000000..78ae66ee2 --- /dev/null +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts @@ -0,0 +1,317 @@ +import { DailyAllocation, OverallAllocation } from "@internal/datastore"; +import { SupplierAllocation } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { Deps } from "../../config/deps"; +import { + calculateSupplierAllocatedFactor, + updateSupplierAllocation, +} from "../supplier-quotas"; + +describe("supplier-quotas", () => { + let mockDeps: jest.Mocked; + + beforeEach(() => { + mockDeps = { + supplierQuotasRepo: { + getOverallAllocation: jest.fn(), + updateOverallAllocation: jest.fn(), + putOverallAllocation: jest.fn(), + getDailyAllocation: jest.fn(), + updateDailyAllocation: jest.fn(), + putDailyAllocation: jest.fn(), + } as any, + logger: { + info: jest.fn(), + } as any, + } as jest.Mocked; + }); + + describe("calculateSupplierAllocatedFactor", () => { + it("should return factor 0 when no overall allocation exists", async () => { + const supplierAllocations: SupplierAllocation[] = [ + { + supplier: "supplier1", + volumeGroup: "vg1", + allocationPercentage: 50, + } as SupplierAllocation, + ]; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(null); + + const result = await calculateSupplierAllocatedFactor( + supplierAllocations, + mockDeps, + ); + + expect(result).toEqual([{ supplierId: "supplier1", factor: 0 }]); + }); + + it("should calculate correct factor when overall allocation exists", async () => { + const supplierAllocations: SupplierAllocation[] = [ + { + supplier: "supplier1", + volumeGroup: "vg1", + allocationPercentage: 50, + } as SupplierAllocation, + ]; + + const overallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(overallAllocation); + + const result = await calculateSupplierAllocatedFactor( + supplierAllocations, + mockDeps, + ); + + expect(result).toEqual([{ supplierId: "supplier1", factor: 2 }]); + }); + + it("should handle multiple suppliers with different allocations", async () => { + const supplierAllocations: SupplierAllocation[] = [ + { + supplier: "supplier1", + volumeGroup: "vg1", + allocationPercentage: 50, + } as SupplierAllocation, + { + supplier: "supplier2", + volumeGroup: "vg1", + allocationPercentage: 50, + } as SupplierAllocation, + ]; + + const overallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 60, + supplier2: 40, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(overallAllocation); + + const result = await calculateSupplierAllocatedFactor( + supplierAllocations, + mockDeps, + ); + + expect(result).toEqual([ + { supplierId: "supplier1", factor: 1.2 }, + { supplierId: "supplier2", factor: 0.8 }, + ]); + }); + + it("should handle supplier not in allocations map with factor 0", async () => { + const supplierAllocations: SupplierAllocation[] = [ + { + supplier: "supplier3", + volumeGroup: "vg1", + allocationPercentage: 50, + } as SupplierAllocation, + ]; + + const overallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(overallAllocation); + + const result = await calculateSupplierAllocatedFactor( + supplierAllocations, + mockDeps, + ); + + expect(result).toEqual([{ supplierId: "supplier3", factor: 0 }]); + }); + + it("should return factor 0 when total allocation is 0", async () => { + const supplierAllocations: SupplierAllocation[] = [ + { + supplier: "supplier1", + volumeGroup: "vg1", + allocationPercentage: 50, + } as SupplierAllocation, + ]; + + const overallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 0, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(overallAllocation); + + const result = await calculateSupplierAllocatedFactor( + supplierAllocations, + mockDeps, + ); + + expect(result).toEqual([{ supplierId: "supplier1", factor: 0 }]); + }); + }); + + describe("updateSupplierAllocation", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-01-15T10:30:00Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should update existing overall allocation and daily allocation", async () => { + const existingOverallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + const existingDailyAllocation: DailyAllocation = { + id: "vg1#DATE#2024-01-15", + date: "2024-01-15", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(existingOverallAllocation); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(existingDailyAllocation); + + await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); + + expect( + mockDeps.supplierQuotasRepo.updateOverallAllocation, + ).toHaveBeenCalledWith("vg1", "supplier1", 150); + expect( + mockDeps.supplierQuotasRepo.updateDailyAllocation, + ).toHaveBeenCalledWith("vg1", "2024-01-15", "supplier1", 150); + }); + + it("should create new overall allocation when none exists", async () => { + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(null); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 100, mockDeps); + + expect( + mockDeps.supplierQuotasRepo.putOverallAllocation, + ).toHaveBeenCalledWith({ + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }); + }); + + it("should create new daily allocation when none exists", async () => { + const existingOverallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(existingOverallAllocation); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); + + expect( + mockDeps.supplierQuotasRepo.putDailyAllocation, + ).toHaveBeenCalledWith({ + id: "vg1#DATE#2024-01-15", + date: "2024-01-15", + volumeGroup: "vg1", + allocations: { + supplier1: 150, + }, + }); + }); + + it("should log when updating existing overall allocation", async () => { + const existingOverallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(existingOverallAllocation); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); + + expect(mockDeps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Existing overall allocation found for volume group", + volumeGroupId: "vg1", + }), + ); + }); + + it("should log when creating new overall allocation", async () => { + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(null); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 100, mockDeps); + + expect(mockDeps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "No overall allocation found for volume group, creating new one", + volumeGroupId: "vg1", + }), + ); + }); + }); +}); From 142fc04feff882353725bfa8e577f44aebf011aa Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 22 Apr 2026 14:21:57 +0100 Subject: [PATCH 18/25] moved types to separate file --- .../src/handler/allocate-handler.ts | 17 +---------------- .../src/handler/allocation-config.ts | 5 +---- .../supplier-allocator/src/handler/types.ts | 18 ++++++++++++++++++ .../src/services/supplier-config.ts | 5 +---- 4 files changed, 21 insertions(+), 24 deletions(-) create mode 100644 lambdas/supplier-allocator/src/handler/types.ts diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index c9d594236..62444258f 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -1,13 +1,11 @@ import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; import { SendMessageCommand } from "@aws-sdk/client-sqs"; -import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { LetterVariant, PackSpecification, Supplier, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; -import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import z from "zod"; import { Unit } from "aws-embedded-metrics"; import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers"; @@ -25,20 +23,7 @@ import { } from "./allocation-config"; import { Deps } from "../config/deps"; - -type SupplierSpec = { - supplierId: string; - specId: string; - priority: number; - billingId: string; -}; - -type SupplierDetails = { - supplierSpec: SupplierSpec; - volumeGroupId: string; -}; - -type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; +import { PreparedEvents, SupplierDetails, SupplierSpec } from "./types"; // small envelope that must exist in all inputs const TypeEnvelope = z.object({ type: z.string().min(1) }); diff --git a/lambdas/supplier-allocator/src/handler/allocation-config.ts b/lambdas/supplier-allocator/src/handler/allocation-config.ts index b87338df9..1ddd6d8fe 100644 --- a/lambdas/supplier-allocator/src/handler/allocation-config.ts +++ b/lambdas/supplier-allocator/src/handler/allocation-config.ts @@ -1,5 +1,3 @@ -import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; -import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { PackSpecification, Supplier, @@ -17,8 +15,7 @@ import { } from "../services/supplier-config"; import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; import { Deps } from "../config/deps"; - -type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; +import { PreparedEvents } from "./types"; export async function eligibleSuppliers( volumeGroup: VolumeGroup, diff --git a/lambdas/supplier-allocator/src/handler/types.ts b/lambdas/supplier-allocator/src/handler/types.ts new file mode 100644 index 000000000..3f62b09a0 --- /dev/null +++ b/lambdas/supplier-allocator/src/handler/types.ts @@ -0,0 +1,18 @@ +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; + +export type SupplierSpec = { + supplierId: string; + specId: string; + priority: number; + billingId: string; +}; + +export type SupplierDetails = { + supplierSpec: SupplierSpec; + volumeGroupId: string; +}; + +export type PreparedEvents = + | LetterRequestPreparedEventV2 + | LetterRequestPreparedEvent; diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 191444d01..23d22140c 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -6,12 +6,9 @@ import { SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; -import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; -import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { Deps } from "../config/deps"; - -type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; +import { PreparedEvents } from "../handler/types"; export async function getVariantDetails( variantId: string, From b2c134121e753a6c31bfa889058efb1e4b9b8a74 Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 22 Apr 2026 14:55:05 +0100 Subject: [PATCH 19/25] rationalise logging --- .../src/handler/allocate-handler.ts | 20 +------------------ .../src/services/supplier-config.ts | 8 -------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 62444258f..c32ed4557 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -32,10 +32,6 @@ function resolveSupplierForVariant( variantId: string, deps: Deps, ): SupplierSpec { - deps.logger.info({ - description: "Resolving supplier for letter variant", - variantId, - }); const supplier = deps.env.VARIANT_MAP[variantId]; if (!supplier) { deps.logger.error({ @@ -135,13 +131,9 @@ async function getSupplierFromConfig( }, volumeGroupId: volumeGroup.id, }; - deps.logger.info({ - description: "Resolved supplier details for letter event", - supplierDetails, - }); return supplierDetails; } catch (error) { - deps.logger.error({ + deps.logger.info({ description: "Error fetching supplier from config", err: error, variantId: letterEvent.data.letterVariantId, @@ -196,12 +188,6 @@ function incrementAllocation( allocation: number, deps: Deps, ) { - deps.logger.info({ - description: "Incrementing allocation for volume group and supplier", - volumeGroupId, - supplierId, - allocation, - }); const groupAllocations = map.get(volumeGroupId) ?? {}; groupAllocations[supplierId] = (groupAllocations[supplierId] ?? 0) + allocation; @@ -217,10 +203,6 @@ async function saveAllocations( deps: Deps, volumeGroupAllocations: VolumeGroupAllocation, ) { - deps.logger.info({ - description: "Saving supplier allocations for volume groups", - volumeGroupAllocations, - }); for (const [volumeGroupId, allocations] of volumeGroupAllocations) { for (const [supplierId, allocation] of Object.entries(allocations)) { await updateSupplierAllocation( diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 23d22140c..9278a3422 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -229,14 +229,6 @@ export async function filterPacksForLetter( ) { filteredPackIds.push(packSpecId); } else { - deps.logger.info({ - description: "Evaluating pack specification constraints for letter", - letterVariantId: letterEvent.data.letterVariantId, - packSpecId, - pageCount: letterEvent.data.pageCount, - constraintValue: packSpec.constraints.sheets.value, - constraintOperator: packSpec.constraints.sheets.operator, - }); const isValid = evaluateContraint( letterEvent.data.pageCount, packSpec.constraints.sheets.value, From 4c0c29e91fba8d8ccabac4ea0800e53f438e2048 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 23 Apr 2026 14:59:27 +0100 Subject: [PATCH 20/25] updated to store total daily allocations per supplier --- .../supplier-quotas-repository.test.ts | 58 ++++-------------- .../src/supplier-quotas-repository.ts | 23 ++++---- internal/datastore/src/types.ts | 3 +- .../__tests__/allocation-config.test.ts | 59 ++++--------------- .../src/handler/allocate-handler.ts | 6 +- .../src/handler/allocation-config.ts | 7 +-- .../__tests__/supplier-quotas.test.ts | 8 +-- .../src/services/supplier-quotas.ts | 10 +--- 8 files changed, 45 insertions(+), 129 deletions(-) diff --git a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts index 37781433e..4c69e93e4 100644 --- a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts @@ -24,15 +24,13 @@ function createOverallAllocationItem( function createDailyAllocationItem( allocationId: string, - volumeGroupId: string, date: string, allocations: Record, ) { return { pk: "ENTITY#daily-allocation", - sk: `ID#${volumeGroupId}#DATE#${date}`, + sk: `ID#${date}`, id: allocationId, - volumeGroup: volumeGroupId, date, allocations, updatedAt: new Date().toISOString(), @@ -157,36 +155,28 @@ describe("SupplierQuotasRepository", () => { test("getDailyAllocation returns correct allocation for existing group and date", async () => { const allocationId = "daily-allocation-123"; - const volumeGroupId = "group-123"; const date = "2023-10-01"; const allocations = { supplier1: 50, supplier2: 75 }; await dbContext.docClient.send( new PutCommand({ TableName: dbContext.config.supplierQuotasTableName, - Item: createDailyAllocationItem( - allocationId, - volumeGroupId, - date, - allocations, - ), + Item: createDailyAllocationItem(allocationId, date, allocations), }), ); - const result = await repository.getDailyAllocation(volumeGroupId, date); + const result = await repository.getDailyAllocation(date); expect(result).toEqual({ id: allocationId, - volumeGroup: volumeGroupId, date, allocations, }); }); - test("getDailyAllocation returns undefined for non-existent group and date", async () => { - const volumeGroupId = "non-existent-group"; - const date = "2023-10-01"; + test("getDailyAllocation returns undefined for non-existent date", async () => { + const date = "2023-09-01"; - const result = await repository.getDailyAllocation(volumeGroupId, date); + const result = await repository.getDailyAllocation(date); expect(result).toBeUndefined(); }); @@ -194,37 +184,26 @@ describe("SupplierQuotasRepository", () => { test("putDailyAllocation stores allocation correctly", async () => { const allocation = { id: "daily-allocation-123", - volumeGroup: "group-123", date: "2023-10-01", allocations: { supplier1: 50, supplier2: 75 }, }; await repository.putDailyAllocation(allocation); - const result = await repository.getDailyAllocation( - "group-123", - "2023-10-01", - ); + const result = await repository.getDailyAllocation("2023-10-01"); expect(result).toEqual(allocation); }); test("updateDailyAllocation creates new allocation when none exists", async () => { - const volumeGroupId = "group-123"; const date = "2023-10-01"; const supplierId = "supplier-123"; const newAllocation = 25; - await repository.updateDailyAllocation( - volumeGroupId, - date, - supplierId, - newAllocation, - ); + await repository.updateDailyAllocation(date, supplierId, newAllocation); - const result = await repository.getDailyAllocation(volumeGroupId, date); + const result = await repository.getDailyAllocation(date); expect(result).toEqual({ - id: `${volumeGroupId}#DATE#${date}`, - volumeGroup: volumeGroupId, + id: `ID#${date}`, date, allocations: { [supplierId]: newAllocation }, }); @@ -232,31 +211,20 @@ describe("SupplierQuotasRepository", () => { test("updateDailyAllocation updates existing allocation", async () => { const allocationId = "daily-allocation-123"; - const volumeGroupId = "group-123"; const date = "2023-10-01"; const supplierId = "supplier-123"; const initialAllocations = { [supplierId]: 50 }; await dbContext.docClient.send( new PutCommand({ TableName: dbContext.config.supplierQuotasTableName, - Item: createDailyAllocationItem( - allocationId, - volumeGroupId, - date, - initialAllocations, - ), + Item: createDailyAllocationItem(allocationId, date, initialAllocations), }), ); const newAllocation = 25; - await repository.updateDailyAllocation( - volumeGroupId, - date, - supplierId, - newAllocation, - ); + await repository.updateDailyAllocation(date, supplierId, newAllocation); - const result = await repository.getDailyAllocation(volumeGroupId, date); + const result = await repository.getDailyAllocation(date); expect(result?.allocations[supplierId]).toBe(75); }); }); diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index 195d8d765..ca0c372f9 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -106,20 +106,19 @@ export class SupplierQuotasRepository { } } - async getDailyAllocation( - groupId: string, - date: string, - ): Promise { + async getDailyAllocation(date: string): Promise { + console.log("Getting daily allocation for date:", date); const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierQuotasTableName, Key: { pk: "ENTITY#daily-allocation", - sk: `ID#${groupId}#DATE#${date}`, + sk: `ID#${date}`, }, }), ); if (!result.Item) { + console.log("No daily allocation found for date:", date); return undefined; } // Strip DynamoDB keys before parsing @@ -130,25 +129,26 @@ export class SupplierQuotasRepository { async putDailyAllocation(allocation: DailyAllocation): Promise { const parsedAllocation = $DailyAllocation.parse(allocation); - await this.ddbClient.send( + console.log("Putting daily allocation:", parsedAllocation); + const output = await this.ddbClient.send( new PutCommand({ TableName: this.config.supplierQuotasTableName, Item: ItemForRecord( "daily-allocation", - `${allocation.volumeGroup}#DATE#${allocation.date}`, + allocation.date, parsedAllocation, ), }), ); + console.log("PutDailyAllocation output:", output); } async updateDailyAllocation( - groupId: string, date: string, supplierId: string, newAllocation: number, ): Promise { - const dailyAllocation = await this.getDailyAllocation(groupId, date); + const dailyAllocation = await this.getDailyAllocation(date); const allocations = dailyAllocation?.allocations ?? {}; const currentAllocation = allocations[supplierId] ?? 0; const updatedAllocation = currentAllocation + newAllocation; @@ -164,7 +164,7 @@ export class SupplierQuotasRepository { TableName: this.config.supplierQuotasTableName, Key: { pk: "ENTITY#daily-allocation", - sk: `ID#${groupId}#DATE#${date}`, + sk: `ID#${date}`, }, UpdateExpression: "SET allocations = :allocations, updatedAt = :updatedAt", @@ -177,9 +177,8 @@ export class SupplierQuotasRepository { } else { // Create new allocation const newDailyAllocation: DailyAllocation = { - id: `${groupId}#DATE#${date}`, + id: `ID#${date}`, date, - volumeGroup: groupId, allocations: { [supplierId]: updatedAllocation }, }; await this.putDailyAllocation(newDailyAllocation); diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 81a0c9e26..53708cedf 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -146,7 +146,6 @@ export const $DailyAllocation = z .object({ id: z.string(), date: z.string(), - volumeGroup: idRef($VolumeGroup, "id"), allocations: z.record( idRef($Supplier, "id"), z.number().int().nonnegative(), @@ -155,7 +154,7 @@ export const $DailyAllocation = z .meta({ title: "DailyAllocation", description: - "The daily allocation for a volume group, including all suppliers", + "The daily allocation for a given date, including all suppliers", }); export type DailyAllocation = z.infer; diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts index 77e8a5c66..fc96143d7 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts @@ -579,11 +579,7 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - const result = await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual([mockSuppliers[0], mockSuppliers[2]]); }); @@ -593,14 +589,9 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(null); - await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(mockDeps.supplierQuotasRepo.getDailyAllocation).toHaveBeenCalledWith( - "volume-group-1", expect.any(String), ); }); @@ -610,11 +601,7 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(null); - const result = await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual(mockSuppliers); }); @@ -632,11 +619,7 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - const result = await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual([ mockSuppliers[0], @@ -661,11 +644,7 @@ describe("filterSuppliersWithCapacity", () => { "Testing filterSuppliersWithCapacity with mockDailyAllocation:", mockDailyAllocation, ); - const result = await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual([mockSuppliers[0], mockSuppliers[1]]); }); @@ -681,11 +660,7 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - const result = await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual(mockSuppliers); }); @@ -701,11 +676,7 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - const result = await filterSuppliersWithCapacity( - [], - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity([], mockDeps); expect(result).toEqual([]); }); @@ -717,7 +688,7 @@ describe("filterSuppliersWithCapacity", () => { ).mockRejectedValue(error); await expect( - filterSuppliersWithCapacity(mockSuppliers, "volume-group-1", mockDeps), + filterSuppliersWithCapacity(mockSuppliers, mockDeps), ).rejects.toThrow("Quotas service error"); }); @@ -730,16 +701,12 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + await filterSuppliersWithCapacity(mockSuppliers, mockDeps); const callArgs = ( mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mock.calls[0]; - const dateArg = callArgs[1]; + const dateArg = callArgs[0]; expect(dateArg).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); @@ -757,11 +724,7 @@ describe("filterSuppliersWithCapacity", () => { mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - const result = await filterSuppliersWithCapacity( - mockSuppliers, - "volume-group-1", - mockDeps, - ); + const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual([ mockSuppliers[0], diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index c32ed4557..87f1a8b50 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -90,11 +90,7 @@ async function getSupplierFromConfig( ); const suppliersForPackWithCapacity: Supplier[] = - await filterSuppliersWithCapacity( - allSuppliersForPack, - volumeGroup.id, - deps, - ); + await filterSuppliersWithCapacity(allSuppliersForPack, deps); // selected supplier id is determined by first calling selectSupplierByFactor for suppliers with capacity and if nothing is returned tryong again with all suppliers for pack const selectedSupplierId = diff --git a/lambdas/supplier-allocator/src/handler/allocation-config.ts b/lambdas/supplier-allocator/src/handler/allocation-config.ts index 1ddd6d8fe..cdb63d5fb 100644 --- a/lambdas/supplier-allocator/src/handler/allocation-config.ts +++ b/lambdas/supplier-allocator/src/handler/allocation-config.ts @@ -77,14 +77,11 @@ export async function suppliersWithValidPack( export async function filterSuppliersWithCapacity( suppliers: Supplier[], - volumeGroupId: string, deps: Deps, ): Promise { const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format - const dailyAllocation = await deps.supplierQuotasRepo.getDailyAllocation( - volumeGroupId, - dailyAllocationDate, - ); + const dailyAllocation = + await deps.supplierQuotasRepo.getDailyAllocation(dailyAllocationDate); if (dailyAllocation) { const suppliersWithCapacity = suppliers.filter((supplier) => { const allocated = dailyAllocation.allocations[supplier.id] ?? 0; diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts index 78ae66ee2..ef37971b8 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts @@ -193,9 +193,8 @@ describe("supplier-quotas", () => { }; const existingDailyAllocation: DailyAllocation = { - id: "vg1#DATE#2024-01-15", + id: "ID#2024-01-15", date: "2024-01-15", - volumeGroup: "vg1", allocations: { supplier1: 100, }, @@ -215,7 +214,7 @@ describe("supplier-quotas", () => { ).toHaveBeenCalledWith("vg1", "supplier1", 150); expect( mockDeps.supplierQuotasRepo.updateDailyAllocation, - ).toHaveBeenCalledWith("vg1", "2024-01-15", "supplier1", 150); + ).toHaveBeenCalledWith("2024-01-15", "supplier1", 150); }); it("should create new overall allocation when none exists", async () => { @@ -260,9 +259,8 @@ describe("supplier-quotas", () => { expect( mockDeps.supplierQuotasRepo.putDailyAllocation, ).toHaveBeenCalledWith({ - id: "vg1#DATE#2024-01-15", + id: "ID#2024-01-15", date: "2024-01-15", - volumeGroup: "vg1", allocations: { supplier1: 150, }, diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index 393a0c182..6ab9ba12c 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -72,22 +72,18 @@ export async function updateSupplierAllocation( await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); } const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format - const dailyAllocation = await deps.supplierQuotasRepo.getDailyAllocation( - volumeGroupId, - dailyAllocationDate, - ); + const dailyAllocation = + await deps.supplierQuotasRepo.getDailyAllocation(dailyAllocationDate); if (dailyAllocation) { await deps.supplierQuotasRepo.updateDailyAllocation( - volumeGroupId, dailyAllocationDate, supplierId, newAllocation, ); } else { const newDailyAllocation: DailyAllocation = { - id: `${volumeGroupId}#DATE#${dailyAllocationDate}`, + id: `ID#${dailyAllocationDate}`, date: dailyAllocationDate, - volumeGroup: volumeGroupId, allocations: { [supplierId]: newAllocation, }, From b93c0587fecf2401fccfd427141c261c68c14f53 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 23 Apr 2026 15:36:42 +0100 Subject: [PATCH 21/25] dev test data and ITs --- ...1-campaign.json => client1-campaign1.json} | 11 +-- .../letter-variant/client1-campaign2.json | 38 ++++++++++ .../letter-variant/client1-campaign3.json | 38 ++++++++++ .../letter-variant/client1-campaign4.json | 38 ++++++++++ .../letter-variant/client1-campaign5.json | 38 ++++++++++ .../letter-variant/client1-campaign6.json | 38 ++++++++++ .../letter-variant/client1-campaign7.json | 38 ++++++++++ .../letter-variant/client1-campaign8.json | 38 ++++++++++ .../letter-variant/client2-admail.json | 35 ++++++++++ .../client3-abnormal-results-braille.json | 31 ++++++++ .../client3-abnormal-results.json | 31 ++++++++ .../letter-variant/client3-colour-admail.json | 35 ++++++++++ .../client3-invites-braille.json | 31 ++++++++ .../letter-variant/client3-invites.json | 31 ++++++++ .../client3-standard-braille.json | 31 ++++++++ .../letter-variant/client3-standard.json | 31 ++++++++ .../letter-variant/notify-audio.json | 30 ++++++++ .../letter-variant/notify-braille.json | 30 ++++++++ .../notify-digital-letters-standard.json | 31 ++++++++ .../letter-variant/notify-first.json | 30 ++++++++ .../notify-standard-colour.json | 34 +++++++++ .../letter-variant/notify-standard-test1.json | 5 +- .../letter-variant/notify-standard.json | 1 + ...1-campaign.json => client1-campaign1.json} | 10 +-- .../pack-specification/client1-campaign2.json | 55 +++++++++++++++ .../pack-specification/client1-campaign3.json | 56 +++++++++++++++ .../pack-specification/client1-campaign4.json | 55 +++++++++++++++ .../pack-specification/client1-campaign5.json | 55 +++++++++++++++ .../pack-specification/client1-campaign6.json | 55 +++++++++++++++ .../pack-specification/client1-campaign8.json | 58 +++++++++++++++ .../client3-abnormal-results-braille.json | 53 ++++++++++++++ .../client3-abnormal-results.json | 53 ++++++++++++++ .../client3-invites-braille.json | 51 ++++++++++++++ .../pack-specification/client3-invites.json | 50 +++++++++++++ .../notify-admail-whitemail.json | 51 ++++++++++++++ .../pack-specification/notify-admail.json | 54 ++++++++++++++ .../pack-specification/notify-audio.json | 48 +++++++++++++ .../notify-braille-whitemail.json | 49 +++++++++++++ .../pack-specification/notify-braille.json | 48 +++++++++++++ .../pack-specification/notify-c4.json | 2 +- .../pack-specification/notify-c5-colour.json | 52 ++++++++++++++ .../notify-c5-whitemail.json | 48 +++++++++++++ .../pack-specification/notify-first.json | 48 +++++++++++++ .../pack-specification/notify-sameday.json | 51 ++++++++++++++ .../supplier1-client1-campaign.json | 7 -- .../supplier1-client1-campaign1.json | 7 ++ .../supplier1-client1-campaign2.json | 7 ++ .../supplier1-client1-campaign3.json | 7 ++ .../supplier1-client1-campaign4.json | 7 ++ .../supplier1-client1-campaign5.json | 7 ++ .../supplier1-client1-campaign6.json | 7 ++ .../supplier1-client1-campaign8.json | 7 ++ ...ier1-client3-abnormal-results-braille.json | 7 ++ .../supplier1-client3-abnormal-results.json | 7 ++ .../supplier1-client3-invites-braille.json | 7 ++ .../supplier1-client3-invites.json | 7 ++ .../supplier1-notify-admail-whitemail.json | 7 ++ .../supplier1-notify-admail.json | 7 ++ .../supplier-pack/supplier1-notify-audio.json | 7 ++ .../supplier1-notify-braille-whitemail.json | 7 ++ .../supplier1-notify-braille.json | 7 ++ .../supplier-pack/supplier1-notify-c4.json | 4 +- .../supplier1-notify-c5-colour.json | 7 ++ .../supplier1-notify-c5-whitemail.json | 7 ++ .../supplier-pack/supplier1-notify-first.json | 7 ++ .../supplier1-notify-sameday.json | 7 ++ .../terraform/components/api/README.md | 2 +- .../api/module_lambda_supplier_allocator.tf | 1 - .../api/module_lambda_upsert_letter.tf | 1 - .../terraform/components/api/variables.tf | 16 ++--- .../src/config/__tests__/deps.test.ts | 7 -- .../src/config/__tests__/env.test.ts | 23 ------ lambdas/supplier-allocator/src/config/env.ts | 15 ---- .../__tests__/allocate-handler.test.ts | 70 ------------------- .../__tests__/allocation-config.test.ts | 4 -- .../src/handler/allocate-handler.ts | 25 +------ tests/helpers/event-fixtures.ts | 2 +- .../helpers/urgent-letter-priority-helper.ts | 28 ++++---- 78 files changed, 1850 insertions(+), 191 deletions(-) rename config/suppliers/letter-variant/{client1-campaign.json => client1-campaign1.json} (75%) create mode 100644 config/suppliers/letter-variant/client1-campaign2.json create mode 100644 config/suppliers/letter-variant/client1-campaign3.json create mode 100644 config/suppliers/letter-variant/client1-campaign4.json create mode 100644 config/suppliers/letter-variant/client1-campaign5.json create mode 100644 config/suppliers/letter-variant/client1-campaign6.json create mode 100644 config/suppliers/letter-variant/client1-campaign7.json create mode 100644 config/suppliers/letter-variant/client1-campaign8.json create mode 100644 config/suppliers/letter-variant/client2-admail.json create mode 100644 config/suppliers/letter-variant/client3-abnormal-results-braille.json create mode 100644 config/suppliers/letter-variant/client3-abnormal-results.json create mode 100644 config/suppliers/letter-variant/client3-colour-admail.json create mode 100644 config/suppliers/letter-variant/client3-invites-braille.json create mode 100644 config/suppliers/letter-variant/client3-invites.json create mode 100644 config/suppliers/letter-variant/client3-standard-braille.json create mode 100644 config/suppliers/letter-variant/client3-standard.json create mode 100644 config/suppliers/letter-variant/notify-audio.json create mode 100644 config/suppliers/letter-variant/notify-braille.json create mode 100644 config/suppliers/letter-variant/notify-digital-letters-standard.json create mode 100644 config/suppliers/letter-variant/notify-first.json create mode 100644 config/suppliers/letter-variant/notify-standard-colour.json rename config/suppliers/pack-specification/{client1-campaign.json => client1-campaign1.json} (83%) create mode 100644 config/suppliers/pack-specification/client1-campaign2.json create mode 100644 config/suppliers/pack-specification/client1-campaign3.json create mode 100644 config/suppliers/pack-specification/client1-campaign4.json create mode 100644 config/suppliers/pack-specification/client1-campaign5.json create mode 100644 config/suppliers/pack-specification/client1-campaign6.json create mode 100644 config/suppliers/pack-specification/client1-campaign8.json create mode 100644 config/suppliers/pack-specification/client3-abnormal-results-braille.json create mode 100644 config/suppliers/pack-specification/client3-abnormal-results.json create mode 100644 config/suppliers/pack-specification/client3-invites-braille.json create mode 100644 config/suppliers/pack-specification/client3-invites.json create mode 100644 config/suppliers/pack-specification/notify-admail-whitemail.json create mode 100644 config/suppliers/pack-specification/notify-admail.json create mode 100644 config/suppliers/pack-specification/notify-audio.json create mode 100644 config/suppliers/pack-specification/notify-braille-whitemail.json create mode 100644 config/suppliers/pack-specification/notify-braille.json create mode 100644 config/suppliers/pack-specification/notify-c5-colour.json create mode 100644 config/suppliers/pack-specification/notify-c5-whitemail.json create mode 100644 config/suppliers/pack-specification/notify-first.json create mode 100644 config/suppliers/pack-specification/notify-sameday.json delete mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign1.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign2.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign3.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign4.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign5.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign6.json create mode 100644 config/suppliers/supplier-pack/supplier1-client1-campaign8.json create mode 100644 config/suppliers/supplier-pack/supplier1-client3-abnormal-results-braille.json create mode 100644 config/suppliers/supplier-pack/supplier1-client3-abnormal-results.json create mode 100644 config/suppliers/supplier-pack/supplier1-client3-invites-braille.json create mode 100644 config/suppliers/supplier-pack/supplier1-client3-invites.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-admail-whitemail.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-admail.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-audio.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-braille-whitemail.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-braille.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-c5-colour.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-c5-whitemail.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-first.json create mode 100644 config/suppliers/supplier-pack/supplier1-notify-sameday.json diff --git a/config/suppliers/letter-variant/client1-campaign.json b/config/suppliers/letter-variant/client1-campaign1.json similarity index 75% rename from config/suppliers/letter-variant/client1-campaign.json rename to config/suppliers/letter-variant/client1-campaign1.json index 0843d9375..5a6b0ad04 100644 --- a/config/suppliers/letter-variant/client1-campaign.json +++ b/config/suppliers/letter-variant/client1-campaign1.json @@ -1,6 +1,6 @@ { "campaignIds": [ - "client1-campaign" + "client1-campaign1" ], "clientId": "client1", "constraints": { @@ -25,12 +25,13 @@ "value": 6 } }, - "description": "Colour printing, campaign envelope, Attachment", - "id": "client1-campaign", - "name": "Client1 - campaign", + "description": "Colour printing, campaign1 envelope, Attachment", + "id": "client1-campaign1", + "name": "Client1 - Campaign1", "packSpecificationIds": [ - "client1-campaign" + "client1-campaign1" ], + "priority": 1, "status": "INT", "type": "STANDARD", "volumeGroupId": "volumeGroup-test3" diff --git a/config/suppliers/letter-variant/client1-campaign2.json b/config/suppliers/letter-variant/client1-campaign2.json new file mode 100644 index 000000000..0df86359d --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign2.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign2" + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail, colour printing", + "id": "client1-campaign2", + "name": "Client1 - CAMPAIGN2", + "packSpecificationIds": [ + "client1-campaign2" + ], + "priority": 1, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client1-campaign3.json b/config/suppliers/letter-variant/client1-campaign3.json new file mode 100644 index 000000000..317cb644c --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign3.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign3" + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail?, colour printing, booklet", + "id": "client1-campaign3", + "name": "Client1 - Campaign 3", + "packSpecificationIds": [ + "client1-campaign3" + ], + "priority": 2, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client1-campaign4.json b/config/suppliers/letter-variant/client1-campaign4.json new file mode 100644 index 000000000..639e5279f --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign4.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign4" + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail, colour printing, campaign4 envelope", + "id": "client1-campaign4", + "name": "Client1 - Campaign 4", + "packSpecificationIds": [ + "client1-campaign4" + ], + "priority": 3, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client1-campaign5.json b/config/suppliers/letter-variant/client1-campaign5.json new file mode 100644 index 000000000..5edbcb7ce --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign5.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign5" + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail, colour printing, Campaign 5 envelope", + "id": "client1-campaign5", + "name": "Client1 - Campaign 5", + "packSpecificationIds": [ + "client1-campaign5" + ], + "priority": 4, + "status": "PROD", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client1-campaign6.json b/config/suppliers/letter-variant/client1-campaign6.json new file mode 100644 index 000000000..2f81b17fd --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign6.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign6" + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Colour printing, Campaign 6 envelope, Attachment", + "id": "client1-campaign6", + "name": "Client1 - Campaign 6", + "packSpecificationIds": [ + "client1-campaign6" + ], + "priority": 5, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client1-campaign7.json b/config/suppliers/letter-variant/client1-campaign7.json new file mode 100644 index 000000000..9902da7dc --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign7.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign7 " + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Economy, colour printing", + "id": "client1-campaign7", + "name": "Client1 - Campaign 7", + "packSpecificationIds": [ + "notify-c5-colour" + ], + "priority": 50, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client1-campaign8.json b/config/suppliers/letter-variant/client1-campaign8.json new file mode 100644 index 000000000..6f2f9db8e --- /dev/null +++ b/config/suppliers/letter-variant/client1-campaign8.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "client1-campaign8" + ], + "clientId": "client1", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail?, colour printing, booklet", + "id": "client1-campaign8", + "name": "Client1 - Campaign 8", + "packSpecificationIds": [ + "client1-campaign8" + ], + "priority": 7, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client2-admail.json b/config/suppliers/letter-variant/client2-admail.json new file mode 100644 index 000000000..f9233001b --- /dev/null +++ b/config/suppliers/letter-variant/client2-admail.json @@ -0,0 +1,35 @@ +{ + "clientId": "Client 2", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Letter with admail postage tariff", + "id": "client2-admail", + "name": "Admail letter", + "packSpecificationIds": [ + "notify-admail" + ], + "priority": 8, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-abnormal-results-braille.json b/config/suppliers/letter-variant/client3-abnormal-results-braille.json new file mode 100644 index 000000000..5e74709a4 --- /dev/null +++ b/config/suppliers/letter-variant/client3-abnormal-results-braille.json @@ -0,0 +1,31 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Same Day, Braille, Whitemail, Booklet", + "id": "client3-abnormal-results-braille", + "name": "Client 3 Braille Abnormal Results", + "packSpecificationIds": [ + "client3-abnormal-results-braille" + ], + "priority": 10, + "status": "INT", + "type": "BRAILLE", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-abnormal-results.json b/config/suppliers/letter-variant/client3-abnormal-results.json new file mode 100644 index 000000000..4e966e1c8 --- /dev/null +++ b/config/suppliers/letter-variant/client3-abnormal-results.json @@ -0,0 +1,31 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Same Day, Whitemail, Booklet", + "id": "client3-abnormal-results", + "name": "Client 3 Abnormal Results", + "packSpecificationIds": [ + "client3-abnormal-results" + ], + "priority": 9, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-colour-admail.json b/config/suppliers/letter-variant/client3-colour-admail.json new file mode 100644 index 000000000..4d05b6272 --- /dev/null +++ b/config/suppliers/letter-variant/client3-colour-admail.json @@ -0,0 +1,35 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Colour printing, whitemail, admail-economy postage tariff", + "id": "client3-colour-admail", + "name": "Client 3 Colour Admail", + "packSpecificationIds": [ + "notify-admail-colour-whitemail" + ], + "priority": 50, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-invites-braille.json b/config/suppliers/letter-variant/client3-invites-braille.json new file mode 100644 index 000000000..2e5ca2545 --- /dev/null +++ b/config/suppliers/letter-variant/client3-invites-braille.json @@ -0,0 +1,31 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Braille, Whitemail, Booklet", + "id": "client3-invites-braille", + "name": "Client 3 Braille Invites", + "packSpecificationIds": [ + "client3-invites-braille" + ], + "priority": 10, + "status": "INT", + "type": "BRAILLE", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-invites.json b/config/suppliers/letter-variant/client3-invites.json new file mode 100644 index 000000000..c44b50f15 --- /dev/null +++ b/config/suppliers/letter-variant/client3-invites.json @@ -0,0 +1,31 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Business Economy, Whitemail, Booklet", + "id": "client3-invites", + "name": "Client 3 Invites", + "packSpecificationIds": [ + "client3-invites" + ], + "priority": 10, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-standard-braille.json b/config/suppliers/letter-variant/client3-standard-braille.json new file mode 100644 index 000000000..c5816ee67 --- /dev/null +++ b/config/suppliers/letter-variant/client3-standard-braille.json @@ -0,0 +1,31 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Braille, Whitemail", + "id": "client3-standard-braille", + "name": "Client 3 Standard Braille Letters", + "packSpecificationIds": [ + "notify-braille-whitemail" + ], + "priority": 50, + "status": "INT", + "type": "BRAILLE", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/client3-standard.json b/config/suppliers/letter-variant/client3-standard.json new file mode 100644 index 000000000..fec49bf3e --- /dev/null +++ b/config/suppliers/letter-variant/client3-standard.json @@ -0,0 +1,31 @@ +{ + "clientId": "client3", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Business Economy, Whitemail", + "id": "client3-standard", + "name": "Client 3 Standard Letters", + "packSpecificationIds": [ + "notify-c5-whitemail" + ], + "priority": 11, + "status": "PROD", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/notify-audio.json b/config/suppliers/letter-variant/notify-audio.json new file mode 100644 index 000000000..77f37ec14 --- /dev/null +++ b/config/suppliers/letter-variant/notify-audio.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Audio CD with standard letter", + "id": "notify-audio", + "name": "Audio CD Letter", + "packSpecificationIds": [ + "notify-audio" + ], + "priority": 50, + "status": "DRAFT", + "type": "AUDIO", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/notify-braille.json b/config/suppliers/letter-variant/notify-braille.json new file mode 100644 index 000000000..c74f82d7e --- /dev/null +++ b/config/suppliers/letter-variant/notify-braille.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Braille letter with standard letter", + "id": "notify-braille", + "name": "Braille Letter", + "packSpecificationIds": [ + "notify-braille" + ], + "priority": 13, + "status": "INT", + "type": "BRAILLE", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/notify-digital-letters-standard.json b/config/suppliers/letter-variant/notify-digital-letters-standard.json new file mode 100644 index 000000000..4ee70411f --- /dev/null +++ b/config/suppliers/letter-variant/notify-digital-letters-standard.json @@ -0,0 +1,31 @@ +{ + "clientId": "digital-letters", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Black printing, economy postage tariff", + "id": "notify-digital-letters-standard", + "name": "Standard Letter Variant for Digital Letters fallback", + "packSpecificationIds": [ + "notify-c5" + ], + "priority": 97, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/notify-first.json b/config/suppliers/letter-variant/notify-first.json new file mode 100644 index 000000000..025177a5d --- /dev/null +++ b/config/suppliers/letter-variant/notify-first.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Black printing, first class postage tariff", + "id": "notify-first", + "name": "First class letter", + "packSpecificationIds": [ + "notify-first" + ], + "priority": 20, + "status": "DRAFT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/notify-standard-colour.json b/config/suppliers/letter-variant/notify-standard-colour.json new file mode 100644 index 000000000..772a4146c --- /dev/null +++ b/config/suppliers/letter-variant/notify-standard-colour.json @@ -0,0 +1,34 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Colour printing, economy postage tariff", + "id": "notify-standard-colour", + "name": "Standard Letter (colour)", + "packSpecificationIds": [ + "notify-c5-colour" + ], + "priority": 99, + "status": "INT", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test3" +} diff --git a/config/suppliers/letter-variant/notify-standard-test1.json b/config/suppliers/letter-variant/notify-standard-test1.json index be12a1d56..f69b59360 100644 --- a/config/suppliers/letter-variant/notify-standard-test1.json +++ b/config/suppliers/letter-variant/notify-standard-test1.json @@ -21,10 +21,9 @@ "id": "notify-standard-test1", "name": "Dev Happy Path", "packSpecificationIds": [ - "notify-c5", - "notify-c4" + "notify-c5" ], - "priority": 10, + "priority": 50, "status": "PROD", "type": "STANDARD", "volumeGroupId": "volumeGroup-test1" diff --git a/config/suppliers/letter-variant/notify-standard.json b/config/suppliers/letter-variant/notify-standard.json index 9f1ec1504..49363c927 100644 --- a/config/suppliers/letter-variant/notify-standard.json +++ b/config/suppliers/letter-variant/notify-standard.json @@ -23,6 +23,7 @@ "packSpecificationIds": [ "notify-c5" ], + "priority": 98, "status": "PROD", "type": "STANDARD", "volumeGroupId": "volumeGroup-test2" diff --git a/config/suppliers/pack-specification/client1-campaign.json b/config/suppliers/pack-specification/client1-campaign1.json similarity index 83% rename from config/suppliers/pack-specification/client1-campaign.json rename to config/suppliers/pack-specification/client1-campaign1.json index e00d68f9a..9dfd0d337 100644 --- a/config/suppliers/pack-specification/client1-campaign.json +++ b/config/suppliers/pack-specification/client1-campaign1.json @@ -1,7 +1,7 @@ { "assembly": { "duplex": true, - "envelopeId": "client1-campaign", + "envelopeId": "client1-campaign1", "features": [ "ADMAIL" ], @@ -18,7 +18,7 @@ }, "printColour": "COLOUR" }, - "billingId": "client1-campaign-billing", + "billingId": "client1-campaign1-billing", "constraints": { "blackCoveragePercentage": { "operator": "LESS_THAN", @@ -42,9 +42,9 @@ } }, "createdAt": "2026-01-12T00:00:00.000Z", - "description": "Envelope and attachment for campaign", - "id": "client1-campaign", - "name": "Client1 - campaign", + "description": "Envelope and attachment for campaign1", + "id": "client1-campaign1", + "name": "Client1 - Campaign1", "postage": { "deliveryDays": 3, "id": "economy", diff --git a/config/suppliers/pack-specification/client1-campaign2.json b/config/suppliers/pack-specification/client1-campaign2.json new file mode 100644 index 000000000..9710d4aa8 --- /dev/null +++ b/config/suppliers/pack-specification/client1-campaign2.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "unbranded-economy-c5", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "admail-economy-colour", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-04-14T00:00:00.000Z", + "description": "Unbranded Envelope, Admail", + "id": "client1-campaign2", + "name": "Client1 - CAMPAIGN2", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "INT", + "updatedAt": "2026-04-14T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client1-campaign3.json b/config/suppliers/pack-specification/client1-campaign3.json new file mode 100644 index 000000000..8fa37adce --- /dev/null +++ b/config/suppliers/pack-specification/client1-campaign3.json @@ -0,0 +1,56 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "insertIds": [ + "client1-campaign3-booklet" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope and booklet for Global Minds", + "id": "client1-campaign3", + "name": "Client1 - Campaign 3", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client1-campaign4.json b/config/suppliers/pack-specification/client1-campaign4.json new file mode 100644 index 000000000..f53893a40 --- /dev/null +++ b/config/suppliers/pack-specification/client1-campaign4.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "client1-campaign4", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope for Campaign 4", + "id": "client1-campaign4", + "name": "Client1 - Campaign 4", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client1-campaign5.json b/config/suppliers/pack-specification/client1-campaign5.json new file mode 100644 index 000000000..348de169f --- /dev/null +++ b/config/suppliers/pack-specification/client1-campaign5.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "client1-campaign5", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope for Campaign 5", + "id": "client1-campaign5", + "name": "Client1 - Campaign 5", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client1-campaign6.json b/config/suppliers/pack-specification/client1-campaign6.json new file mode 100644 index 000000000..86222063b --- /dev/null +++ b/config/suppliers/pack-specification/client1-campaign6.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "client1-campaign6", + "insertIds": [ + "client1-campaign6-leaflet" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-02-04T00:00:00.000Z", + "description": "Envelope and insert for Campaign 6", + "id": "client1-campaign6", + "name": "Client1 - Campaign 6", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-02-04T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client1-campaign8.json b/config/suppliers/pack-specification/client1-campaign8.json new file mode 100644 index 000000000..2b20af643 --- /dev/null +++ b/config/suppliers/pack-specification/client1-campaign8.json @@ -0,0 +1,58 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "insertIds": [ + "client1-campaign8-leaflet" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Insert for Campaign 8", + "id": "client1-campaign8", + "name": "Client1 - Campaign 8", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client3-abnormal-results-braille.json b/config/suppliers/pack-specification/client3-abnormal-results-braille.json new file mode 100644 index 000000000..c1a728ad8 --- /dev/null +++ b/config/suppliers/pack-specification/client3-abnormal-results-braille.json @@ -0,0 +1,53 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille-whitemail", + "features": [ + "BRAILLE", + "SAME_DAY" + ], + "insertIds": [ + "CSP15" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "sameday-insert-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "client3-abnormal-results-braille", + "name": "client3 Abnormal Results Braille", + "postage": { + "deliveryDays": 1, + "id": "articles-blind", + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client3-abnormal-results.json b/config/suppliers/pack-specification/client3-abnormal-results.json new file mode 100644 index 000000000..77e71aef5 --- /dev/null +++ b/config/suppliers/pack-specification/client3-abnormal-results.json @@ -0,0 +1,53 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5-whitemail", + "features": [ + "SAME_DAY" + ], + "insertIds": [ + "CSP15" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "sameday-insert-c5", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "client3-abnormal-results", + "name": "client3 Abnormal Results", + "postage": { + "deliveryDays": 1, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client3-invites-braille.json b/config/suppliers/pack-specification/client3-invites-braille.json new file mode 100644 index 000000000..89a8f11f5 --- /dev/null +++ b/config/suppliers/pack-specification/client3-invites-braille.json @@ -0,0 +1,51 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille-whitemail", + "features": [ + "BRAILLE" + ], + "insertIds": [ + "CSP14" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "insert-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "client3-invites-braille", + "name": "client3 Invites Braille", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/client3-invites.json b/config/suppliers/pack-specification/client3-invites.json new file mode 100644 index 000000000..8998c07e6 --- /dev/null +++ b/config/suppliers/pack-specification/client3-invites.json @@ -0,0 +1,50 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5-whitemail", + "insertIds": [ + "CSP14" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "client3-invites", + "name": "client3 Invites", + "postage": { + "deliveryDays": 3, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-admail-whitemail.json b/config/suppliers/pack-specification/notify-admail-whitemail.json new file mode 100644 index 000000000..0132b71f9 --- /dev/null +++ b/config/suppliers/pack-specification/notify-admail-whitemail.json @@ -0,0 +1,51 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Admail economy tariff with whitemail envelope", + "id": "notify-admail-whitemail", + "name": "Admail (whitemail)", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-admail.json b/config/suppliers/pack-specification/notify-admail.json new file mode 100644 index 000000000..7344a939b --- /dev/null +++ b/config/suppliers/pack-specification/notify-admail.json @@ -0,0 +1,54 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "admail", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Admail economy tariff, B&W", + "id": "notify-admail", + "name": "Admail", + "postage": { + "id": "admail", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 2 +} diff --git a/config/suppliers/pack-specification/notify-audio.json b/config/suppliers/pack-specification/notify-audio.json new file mode 100644 index 000000000..eb2c52336 --- /dev/null +++ b/config/suppliers/pack-specification/notify-audio.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "audio", + "features": [ + "AUDIO" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-audio", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "notify-audio", + "name": "Audio CD", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-braille-whitemail.json b/config/suppliers/pack-specification/notify-braille-whitemail.json new file mode 100644 index 000000000..2ef1f05f6 --- /dev/null +++ b/config/suppliers/pack-specification/notify-braille-whitemail.json @@ -0,0 +1,49 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille-whitemail", + "features": [ + "BRAILLE" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "notify-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Braille pack with whitemail return address", + "id": "notify-braille-whitemail", + "name": "Braille letter (whitemail)", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-braille.json b/config/suppliers/pack-specification/notify-braille.json new file mode 100644 index 000000000..4417ce98d --- /dev/null +++ b/config/suppliers/pack-specification/notify-braille.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille", + "features": [ + "BRAILLE" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "notify-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "notify-braille", + "name": "Braille Letter", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-c4.json b/config/suppliers/pack-specification/notify-c4.json index dc42c0f1b..e788b3fc1 100644 --- a/config/suppliers/pack-specification/notify-c4.json +++ b/config/suppliers/pack-specification/notify-c4.json @@ -42,7 +42,7 @@ "maxWeightGrams": 500, "size": "LARGE" }, - "status": "DRAFT", + "status": "PROD", "updatedAt": "2026-01-12T00:00:00.000Z", "version": 1 } diff --git a/config/suppliers/pack-specification/notify-c5-colour.json b/config/suppliers/pack-specification/notify-c5-colour.json new file mode 100644 index 000000000..ff8aca918 --- /dev/null +++ b/config/suppliers/pack-specification/notify-c5-colour.json @@ -0,0 +1,52 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "notify-c5-colour", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "C5 pack with colour printing", + "id": "notify-c5-colour", + "name": "Notify standard (colour)", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-c5-whitemail.json b/config/suppliers/pack-specification/notify-c5-whitemail.json new file mode 100644 index 000000000..0341599ea --- /dev/null +++ b/config/suppliers/pack-specification/notify-c5-whitemail.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5-whitemail", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-c5", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "C5 pack with whitemail return address", + "id": "notify-c5-whitemail", + "name": "Notify standard (whitemail)", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-first.json b/config/suppliers/pack-specification/notify-first.json new file mode 100644 index 000000000..81b4edfd6 --- /dev/null +++ b/config/suppliers/pack-specification/notify-first.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-first", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "First class postage tariff", + "id": "notify-first", + "name": "First class", + "postage": { + "deliveryDays": 2, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-sameday.json b/config/suppliers/pack-specification/notify-sameday.json new file mode 100644 index 000000000..de08d324b --- /dev/null +++ b/config/suppliers/pack-specification/notify-sameday.json @@ -0,0 +1,51 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5", + "features": [ + "SAME_DAY" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "sameday", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Same day production and dispatch", + "id": "notify-sameday", + "name": "Same day dispatch", + "postage": { + "deliveryDays": 1, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign.json b/config/suppliers/supplier-pack/supplier1-client1-campaign.json deleted file mode 100644 index 3340acdb1..000000000 --- a/config/suppliers/supplier-pack/supplier1-client1-campaign.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "approval": "APPROVED", - "id": "supplier1-client1-campaign", - "packSpecificationId": "client1-campaign", - "status": "PROD", - "supplierId": "supplier1" -} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign1.json b/config/suppliers/supplier-pack/supplier1-client1-campaign1.json new file mode 100644 index 000000000..bb20e5c0b --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign1.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client1-campaign1", + "packSpecificationId": "client1-campaign1", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign2.json b/config/suppliers/supplier-pack/supplier1-client1-campaign2.json new file mode 100644 index 000000000..2d5e0e3cc --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign2.json @@ -0,0 +1,7 @@ +{ + "approval": "SUBMITTED", + "id": "supplier1-client1-campaign2", + "packSpecificationId": "client1-campaign2", + "status": "INT", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign3.json b/config/suppliers/supplier-pack/supplier1-client1-campaign3.json new file mode 100644 index 000000000..3510c7648 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign3.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client1-campaign3", + "packSpecificationId": "client1-campaign3", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign4.json b/config/suppliers/supplier-pack/supplier1-client1-campaign4.json new file mode 100644 index 000000000..1bdbd4531 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign4.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client1-campaign4", + "packSpecificationId": "client1-campaign4", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign5.json b/config/suppliers/supplier-pack/supplier1-client1-campaign5.json new file mode 100644 index 000000000..c35220c84 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign5.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client1-campaign5", + "packSpecificationId": "client1-campaign5", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign6.json b/config/suppliers/supplier-pack/supplier1-client1-campaign6.json new file mode 100644 index 000000000..50ba0696a --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign6.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client1-campaign6", + "packSpecificationId": "client1-campaign6", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client1-campaign8.json b/config/suppliers/supplier-pack/supplier1-client1-campaign8.json new file mode 100644 index 000000000..7242676c6 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client1-campaign8.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client1-campaign8", + "packSpecificationId": "client1-campaign8", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client3-abnormal-results-braille.json b/config/suppliers/supplier-pack/supplier1-client3-abnormal-results-braille.json new file mode 100644 index 000000000..90a6fb2cd --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client3-abnormal-results-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client3-abnormal-results-braille", + "packSpecificationId": "client3-abnormal-results-braille", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client3-abnormal-results.json b/config/suppliers/supplier-pack/supplier1-client3-abnormal-results.json new file mode 100644 index 000000000..8346208fc --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client3-abnormal-results.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client3-abnormal-results", + "packSpecificationId": "client3-abnormal-results", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client3-invites-braille.json b/config/suppliers/supplier-pack/supplier1-client3-invites-braille.json new file mode 100644 index 000000000..11258414a --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client3-invites-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client3-invites-braille", + "packSpecificationId": "client3-invites-braille", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-client3-invites.json b/config/suppliers/supplier-pack/supplier1-client3-invites.json new file mode 100644 index 000000000..8660ab0d6 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-client3-invites.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-client3-invites", + "packSpecificationId": "client3-invites", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-admail-whitemail.json b/config/suppliers/supplier-pack/supplier1-notify-admail-whitemail.json new file mode 100644 index 000000000..faba430bd --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-admail-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-admail-whitemail", + "packSpecificationId": "notify-admail-whitemail", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-admail.json b/config/suppliers/supplier-pack/supplier1-notify-admail.json new file mode 100644 index 000000000..e5b7580d5 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-admail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-admail", + "packSpecificationId": "notify-admail", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-audio.json b/config/suppliers/supplier-pack/supplier1-notify-audio.json new file mode 100644 index 000000000..71492cdae --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-audio.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-audio", + "packSpecificationId": "notify-audio", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-braille-whitemail.json b/config/suppliers/supplier-pack/supplier1-notify-braille-whitemail.json new file mode 100644 index 000000000..16c2a6834 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-braille-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-braille-whitemail", + "packSpecificationId": "notify-braille-whitemail", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-braille.json b/config/suppliers/supplier-pack/supplier1-notify-braille.json new file mode 100644 index 000000000..6da973d5c --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-braille", + "packSpecificationId": "notify-braille", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-c4.json b/config/suppliers/supplier-pack/supplier1-notify-c4.json index c804a0d5a..1b071883a 100644 --- a/config/suppliers/supplier-pack/supplier1-notify-c4.json +++ b/config/suppliers/supplier-pack/supplier1-notify-c4.json @@ -1,7 +1,7 @@ { - "approval": "DRAFT", + "approval": "APPROVED", "id": "supplier1-notify-c4", "packSpecificationId": "notify-c4", - "status": "DRAFT", + "status": "PROD", "supplierId": "supplier1" } diff --git a/config/suppliers/supplier-pack/supplier1-notify-c5-colour.json b/config/suppliers/supplier-pack/supplier1-notify-c5-colour.json new file mode 100644 index 000000000..d0c9e8c67 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-c5-colour.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-c5-colour", + "packSpecificationId": "notify-c5-colour", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-c5-whitemail.json b/config/suppliers/supplier-pack/supplier1-notify-c5-whitemail.json new file mode 100644 index 000000000..4f4ef373f --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-c5-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-c5-whitemail", + "packSpecificationId": "notify-c5-whitemail", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-first.json b/config/suppliers/supplier-pack/supplier1-notify-first.json new file mode 100644 index 000000000..21a5ab2b7 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-first.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-first", + "packSpecificationId": "notify-first", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier1-notify-sameday.json b/config/suppliers/supplier-pack/supplier1-notify-sameday.json new file mode 100644 index 000000000..a051ea322 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-sameday.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-sameday", + "packSpecificationId": "notify-sameday", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 00196a0bc..72b976bf0 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,7 +37,7 @@ No requirements. | [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | | [letter\_event\_source](#input\_letter\_event\_source) | Source value to use for the letter status event updates | `string` | `"/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status"` | no | | [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no | -| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string, priority = number, billingId = string }))` |
{
"digitrials-aspiring": {
"billingId": "digitrials-aspiring-billing",
"priority": "0",
"specId": "digitrials-aspiring",
"supplierId": "supplier1"
},
"digitrials-dmapp": {
"billingId": "notify-admail-billing",
"priority": "1",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"digitrials-globalminds": {
"billingId": "digitrials-globalminds-billing",
"priority": "2",
"specId": "digitrials-globalminds",
"supplierId": "supplier1"
},
"digitrials-mymelanoma": {
"billingId": "digitrials-mymelanoma-billing",
"priority": "3",
"specId": "digitrials-mymelanoma",
"supplierId": "supplier1"
},
"digitrials-ofh": {
"billingId": "digitrials-ofh-billing",
"priority": "4",
"specId": "digitrials-ofh",
"supplierId": "supplier1"
},
"digitrials-prostateprogress": {
"billingId": "digitrials-prostateprogress-billing",
"priority": "5",
"specId": "digitrials-prostateprogress",
"supplierId": "supplier1"
},
"digitrials-protectc": {
"billingId": "notify-c5-colour-billing",
"priority": "6",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
},
"digitrials-restore": {
"billingId": "digitrials-restore-billing",
"priority": "7",
"specId": "digitrials-restore",
"supplierId": "supplier1"
},
"gpreg-admail": {
"billingId": "notify-admail-billing",
"priority": "8",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"nces-abnormal-results": {
"billingId": "nces-abnormal-results-billing",
"priority": "9",
"specId": "nces-abnormal-results",
"supplierId": "supplier1"
},
"nces-abnormal-results-braille": {
"billingId": "nces-abnormal-results-braille-billing",
"priority": "10",
"specId": "nces-abnormal-results-braille",
"supplierId": "supplier1"
},
"nces-invites": {
"billingId": "nces-invites-billing",
"priority": "10",
"specId": "nces-invites",
"supplierId": "supplier1"
},
"nces-invites-braille": {
"billingId": "nces-invites-braille-billing",
"priority": "10",
"specId": "nces-invites-braille",
"supplierId": "supplier1"
},
"nces-standard": {
"billingId": "notify-c5-whitemail-billing",
"priority": "11",
"specId": "notify-c5-whitemail",
"supplierId": "supplier1"
},
"nces-standard-braille": {
"billingId": "notify-braille-whitemail-billing",
"priority": "12",
"specId": "notify-braille-whitemail",
"supplierId": "supplier1"
},
"notify-braille": {
"billingId": "notify-braille-billing",
"priority": "13",
"specId": "notify-braille",
"supplierId": "supplier1"
},
"notify-digital-letters-standard": {
"billingId": "notify-c5-billing",
"priority": "97",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard": {
"billingId": "notify-c5-billing",
"priority": "98",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard-colour": {
"billingId": "notify-c5-colour-billing",
"priority": "99",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
}
}
| no | +| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string, priority = number, billingId = string }))` |
{
"client1-aspiring": {
"billingId": "client1-aspiring-billing",
"priority": "0",
"specId": "client1-aspiring",
"supplierId": "supplier1"
},
"client1-dmapp": {
"billingId": "notify-admail-billing",
"priority": "1",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"client1-globalminds": {
"billingId": "client1-globalminds-billing",
"priority": "2",
"specId": "client1-globalminds",
"supplierId": "supplier1"
},
"client1-mymelanoma": {
"billingId": "client1-mymelanoma-billing",
"priority": "3",
"specId": "client1-mymelanoma",
"supplierId": "supplier1"
},
"client1-ofh": {
"billingId": "client1-ofh-billing",
"priority": "4",
"specId": "client1-ofh",
"supplierId": "supplier1"
},
"client1-prostateprogress": {
"billingId": "client1-prostateprogress-billing",
"priority": "5",
"specId": "client1-prostateprogress",
"supplierId": "supplier1"
},
"client1-protectc": {
"billingId": "notify-c5-colour-billing",
"priority": "6",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
},
"client1-restore": {
"billingId": "client1-restore-billing",
"priority": "7",
"specId": "client1-restore",
"supplierId": "supplier1"
},
"gpreg-admail": {
"billingId": "notify-admail-billing",
"priority": "8",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"nces-abnormal-results": {
"billingId": "nces-abnormal-results-billing",
"priority": "9",
"specId": "nces-abnormal-results",
"supplierId": "supplier1"
},
"nces-abnormal-results-braille": {
"billingId": "nces-abnormal-results-braille-billing",
"priority": "10",
"specId": "nces-abnormal-results-braille",
"supplierId": "supplier1"
},
"nces-invites": {
"billingId": "nces-invites-billing",
"priority": "10",
"specId": "nces-invites",
"supplierId": "supplier1"
},
"nces-invites-braille": {
"billingId": "nces-invites-braille-billing",
"priority": "10",
"specId": "nces-invites-braille",
"supplierId": "supplier1"
},
"nces-standard": {
"billingId": "notify-c5-whitemail-billing",
"priority": "11",
"specId": "notify-c5-whitemail",
"supplierId": "supplier1"
},
"nces-standard-braille": {
"billingId": "notify-braille-whitemail-billing",
"priority": "12",
"specId": "notify-braille-whitemail",
"supplierId": "supplier1"
},
"notify-braille": {
"billingId": "notify-braille-billing",
"priority": "13",
"specId": "notify-braille",
"supplierId": "supplier1"
},
"notify-digital-letters-standard": {
"billingId": "notify-c5-billing",
"priority": "97",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard": {
"billingId": "notify-c5-billing",
"priority": "98",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard-colour": {
"billingId": "notify-c5-colour-billing",
"priority": "99",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
}
}
| no | | [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | | [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | | [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no | diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index fc0882302..080b44ca4 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -35,7 +35,6 @@ module "supplier_allocator" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = merge(local.common_lambda_env_vars, { - VARIANT_MAP = jsonencode(var.letter_variant_map) UPSERT_LETTERS_QUEUE_URL = module.sqs_letter_updates.sqs_queue_url }) } diff --git a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf index bac0f3a23..f2ac01628 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -35,7 +35,6 @@ module "upsert_letter" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = merge(local.common_lambda_env_vars, { - VARIANT_MAP = jsonencode(var.letter_variant_map), IDEMPOTENCY_TABLE_NAME = aws_dynamodb_table.idempotency.name }) } diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index 363385886..afe841766 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -138,14 +138,14 @@ variable "eventpub_control_plane_bus_arn" { variable "letter_variant_map" { type = map(object({ supplierId = string, specId = string, priority = number, billingId = string })) default = { - "digitrials-aspiring" = { supplierId = "supplier1", specId = "digitrials-aspiring", priority = "0", billingId = "digitrials-aspiring-billing" }, - "digitrials-dmapp" = { supplierId = "supplier1", specId = "notify-admail", priority = "1", billingId = "notify-admail-billing" }, - "digitrials-globalminds" = { supplierId = "supplier1", specId = "digitrials-globalminds", priority = "2", billingId = "digitrials-globalminds-billing" }, - "digitrials-mymelanoma" = { supplierId = "supplier1", specId = "digitrials-mymelanoma", priority = "3", billingId = "digitrials-mymelanoma-billing" }, - "digitrials-ofh" = { supplierId = "supplier1", specId = "digitrials-ofh", priority = "4", billingId = "digitrials-ofh-billing" }, - "digitrials-prostateprogress" = { supplierId = "supplier1", specId = "digitrials-prostateprogress", priority = "5", billingId = "digitrials-prostateprogress-billing" }, - "digitrials-protectc" = { supplierId = "supplier1", specId = "notify-c5-colour", priority = "6", billingId = "notify-c5-colour-billing" }, - "digitrials-restore" = { supplierId = "supplier1", specId = "digitrials-restore", priority = "7", billingId = "digitrials-restore-billing" }, + "client1-aspiring" = { supplierId = "supplier1", specId = "client1-aspiring", priority = "0", billingId = "client1-aspiring-billing" }, + "client1-dmapp" = { supplierId = "supplier1", specId = "notify-admail", priority = "1", billingId = "notify-admail-billing" }, + "client1-globalminds" = { supplierId = "supplier1", specId = "client1-globalminds", priority = "2", billingId = "client1-globalminds-billing" }, + "client1-mymelanoma" = { supplierId = "supplier1", specId = "client1-mymelanoma", priority = "3", billingId = "client1-mymelanoma-billing" }, + "client1-ofh" = { supplierId = "supplier1", specId = "client1-ofh", priority = "4", billingId = "client1-ofh-billing" }, + "client1-prostateprogress" = { supplierId = "supplier1", specId = "client1-prostateprogress", priority = "5", billingId = "client1-prostateprogress-billing" }, + "client1-protectc" = { supplierId = "supplier1", specId = "notify-c5-colour", priority = "6", billingId = "notify-c5-colour-billing" }, + "client1-restore" = { supplierId = "supplier1", specId = "client1-restore", priority = "7", billingId = "client1-restore-billing" }, "gpreg-admail" = { supplierId = "supplier1", specId = "notify-admail", priority = "8", billingId = "notify-admail-billing" }, "nces-abnormal-results" = { supplierId = "supplier1", specId = "nces-abnormal-results", priority = "9", billingId = "nces-abnormal-results-billing" }, "nces-abnormal-results-braille" = { supplierId = "supplier1", specId = "nces-abnormal-results-braille", priority = "10", billingId = "nces-abnormal-results-braille-billing" }, diff --git a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts index 6cd95d077..88d04eab5 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts @@ -4,13 +4,6 @@ describe("createDependenciesContainer", () => { const env = { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - billingId: "billing1", - }, - }, }; beforeEach(() => { diff --git a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts index 78e2d0a6a..1f4da34cb 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts @@ -1,4 +1,3 @@ -import { ZodError } from "zod"; /* eslint-disable @typescript-eslint/no-require-imports */ /* Allow require imports to enable re-import of modules */ @@ -17,34 +16,12 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable"; process.env.SUPPLIER_QUOTAS_TABLE_NAME = "SupplierQuotasTable"; - process.env.VARIANT_MAP = `{ - "lv1": { - "supplierId": "supplier1", - "specId": "spec1", - "priority": 10, - "billingId": "billing1" - } - }`; const { envVars } = require("../env"); expect(envVars).toEqual({ SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - priority: 10, - billingId: "billing1", - }, - }, }); }); - - it("should throw if a required env var is missing", () => { - process.env.VARIANT_MAP = undefined; - - expect(() => require("../env")).toThrow(ZodError); - }); }); diff --git a/lambdas/supplier-allocator/src/config/env.ts b/lambdas/supplier-allocator/src/config/env.ts index 657d95b88..a155e4dbc 100644 --- a/lambdas/supplier-allocator/src/config/env.ts +++ b/lambdas/supplier-allocator/src/config/env.ts @@ -1,24 +1,9 @@ import { z } from "zod"; -const LetterVariantSchema = z.record( - z.string(), - z.object({ - supplierId: z.string(), - specId: z.string(), - priority: z.int().min(0).max(99), // Lower number represents a higher priority - billingId: z.string(), - }), -); -export type LetterVariant = z.infer; - const EnvVarsSchema = z.object({ SUPPLIER_CONFIG_TABLE_NAME: z.string(), SUPPLIER_QUOTAS_TABLE_NAME: z.string(), PINO_LOG_LEVEL: z.coerce.string().optional(), - VARIANT_MAP: z.string().transform((str, _) => { - const parsed = JSON.parse(str); - return LetterVariantSchema.parse(parsed); - }), }); export type EnvVars = z.infer; diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 5408faac6..a8cabe2d9 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -222,14 +222,6 @@ describe("createSupplierAllocatorHandler", () => { env: { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - priority: 1, - billingId: "billing1", - }, - }, } as EnvVars, sqsClient: mockSqsClient, supplierConfigRepo: mockedSupplierConfigRepo, @@ -433,68 +425,6 @@ describe("createSupplierAllocatorHandler", () => { ); }); - test("returns batch failure when variant mapping is missing", async () => { - const preparedEvent = createPreparedV2Event(); - preparedEvent.data.letterVariantId = "missing-variant"; - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - // Override variant map to be empty for this test - mockedDeps.env.VARIANT_MAP = {} as any; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(1); - expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); - expect( - (mockedDeps.logger.error as jest.Mock).mock.calls.length, - ).toBeGreaterThan(0); - expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( - expect.objectContaining({ - description: "No supplier mapping found for variant", - }), - ); - }); - - test("returns batch failure when variant mapping is missing for multiple events", async () => { - const preparedEvent1 = createPreparedV2Event(); - preparedEvent1.data.letterVariantId = "missing-variant1"; - const preparedEvent2 = createPreparedV2Event(); - preparedEvent2.data.letterVariantId = "missing-variant2"; - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent1)), - createSqsRecord("msg2", JSON.stringify(preparedEvent2)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - // Override variant map to be empty for this test - mockedDeps.env.VARIANT_MAP = {} as any; - - const handler = createSupplierAllocatorHandler(mockedDeps); - const result = await handler(evt, {} as any, {} as any); - if (!result) throw new Error("expected BatchResponse, got void"); - - expect(result.batchItemFailures).toHaveLength(2); - expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); - expect(result.batchItemFailures[1].itemIdentifier).toBe("msg2"); - expect( - (mockedDeps.logger.error as jest.Mock).mock.calls.length, - ).toBeGreaterThan(0); - expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( - expect.objectContaining({ - description: "No supplier mapping found for variant", - }), - ); - }); - test("handles SQS send errors and returns batch failure", async () => { const preparedEvent = createPreparedV2Event(); diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts index fc96143d7..5d6f02079 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts @@ -640,10 +640,6 @@ describe("filterSuppliersWithCapacity", () => { ( mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock ).mockResolvedValue(mockDailyAllocation); - console.log( - "Testing filterSuppliersWithCapacity with mockDailyAllocation:", - mockDailyAllocation, - ); const result = await filterSuppliersWithCapacity(mockSuppliers, mockDeps); expect(result).toEqual([mockSuppliers[0], mockSuppliers[1]]); diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 87f1a8b50..4eddc981f 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -23,27 +23,11 @@ import { } from "./allocation-config"; import { Deps } from "../config/deps"; -import { PreparedEvents, SupplierDetails, SupplierSpec } from "./types"; +import { PreparedEvents, SupplierDetails } from "./types"; // small envelope that must exist in all inputs const TypeEnvelope = z.object({ type: z.string().min(1) }); -function resolveSupplierForVariant( - variantId: string, - deps: Deps, -): SupplierSpec { - const supplier = deps.env.VARIANT_MAP[variantId]; - if (!supplier) { - deps.logger.error({ - description: "No supplier mapping found for variant", - variantId, - }); - throw new Error(`No supplier mapping for variantId: ${variantId}`); - } - - return supplier; -} - function validateType(event: unknown) { const env = TypeEnvelope.safeParse(event); if (!env.success) { @@ -138,10 +122,6 @@ async function getSupplierFromConfig( } } -function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec { - return resolveSupplierForVariant(letterEvent.data.letterVariantId, deps); -} - type AllocationMetrics = Map>; type VolumeGroupAllocation = Map>; @@ -232,8 +212,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { validateType(letterEvent); - const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps); - const supplierDetails = await getSupplierFromConfig( + const supplierDetails: SupplierDetails = await getSupplierFromConfig( letterEvent as PreparedEvents, deps, ); diff --git a/tests/helpers/event-fixtures.ts b/tests/helpers/event-fixtures.ts index e679ce950..4e71f6f2e 100644 --- a/tests/helpers/event-fixtures.ts +++ b/tests/helpers/event-fixtures.ts @@ -16,7 +16,7 @@ export function createPreparedV1Event(overrides: Record = {}) { dataschemaversion: "1.0.0", data: { domainId: "fe658e11-0ffc-44f4-8ad6-0fafe75bfeee", - letterVariantId: "digitrials-aspiring", + letterVariantId: "client1-campaign1", requestId: "request1", requestItemId: "requestItem1", requestItemPlanId: "requestItemPlan1", diff --git a/tests/helpers/urgent-letter-priority-helper.ts b/tests/helpers/urgent-letter-priority-helper.ts index 9382b35c0..9b36c7059 100644 --- a/tests/helpers/urgent-letter-priority-helper.ts +++ b/tests/helpers/urgent-letter-priority-helper.ts @@ -12,21 +12,21 @@ import { sendSnsEvent } from "./send-sns-event"; // Values for CI/CD are kept in group_nhs-notify-supplier-api-dev.tfvars in the nhs-notify-internal repo // If running locally see default of variant_map in infrastructure/terraform/components/api/variables.tf export const variantUrgencyMap: Record = { - "digitrials-aspiring": 0, - "digitrials-dmapp": 1, - "digitrials-globalminds": 2, - "digitrials-mymelanoma": 3, - "digitrials-ofh": 4, - "digitrials-prostateprogress": 5, - "digitrials-protectc": 6, - "digitrials-restore": 7, + "client1-campaign1": 0, + "client1-campaign2": 1, + "client1-campaign3": 2, + "client1-campaign4": 3, + "client1-campaign5": 4, + "client1-campaign6": 5, + "client1-campaign7": 6, + "client1-campaign8": 7, "gpreg-admail": 8, - "nces-abnormal-results": 9, - "nces-abnormal-results-braille": 10, - "nces-invites": 10, - "nces-invites-braille": 10, - "nces-standard": 11, - "nces-standard-braille": 12, + "client3-abnormal-results": 9, + "client3-abnormal-results-braille": 10, + "client3-invites": 10, + "client3-invites-braille": 10, + "client3-standard": 11, + "client3-standard-braille": 12, "notify-braille": 13, "notify-digital-letters-standard": 97, "notify-standard": 98, From 45c4372fd3c2fc02bf3047b56d75ff3e9109e48a Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 23 Apr 2026 16:11:49 +0100 Subject: [PATCH 22/25] turn it on! --- .../__tests__/allocate-handler.test.ts | 38 +++++-------------- .../src/handler/allocate-handler.ts | 11 +++--- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index a8cabe2d9..7362cfc2d 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -151,6 +151,7 @@ function setupDefaultMocks() { (supplierConfig.getVariantDetails as jest.Mock).mockResolvedValue({ id: "v1", volumeGroupId: "g1", + priority: 1, }); (supplierConfig.getVolumeGroupDetails as jest.Mock).mockResolvedValue({ id: "g1", @@ -161,19 +162,18 @@ function setupDefaultMocks() { suppliers: [{ id: "s1", name: "Supplier 1", status: "PROD" }], }); (allocationConfig.preferredSupplierPack as jest.Mock).mockResolvedValue({ - id: "pack-spec-1", + id: "spec1", type: "A4", colour: false, duplex: false, + billingId: "billing1", }); (allocationConfig.filterSuppliersWithCapacity as jest.Mock).mockResolvedValue( [{ id: "s1", name: "Supplier 1", status: "PROD" }], ); - (allocationConfig.selectSupplierByFactor as jest.Mock).mockResolvedValue({ - id: "s1", - name: "Supplier 1", - status: "PROD", - }); + (allocationConfig.selectSupplierByFactor as jest.Mock).mockResolvedValue( + "supplier1", + ); (allocationConfig.suppliersWithValidPack as jest.Mock).mockResolvedValue([ { id: "s1", name: "Supplier 1", status: "PROD" }, ]); @@ -326,24 +326,6 @@ describe("createSupplierAllocatorHandler", () => { expect(messageBody.letterEvent.data.domainId).toBe("letter-test"); }); - test("resolves correct supplier spec from variant map", async () => { - const preparedEvent = createPreparedV2Event(); - - const evt: SQSEvent = createSQSEvent([ - createSqsRecord("msg1", JSON.stringify(preparedEvent)), - ]); - - process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; - - const handler = createSupplierAllocatorHandler(mockedDeps); - await handler(evt, {} as any, {} as any); - - const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0]; - const messageBody = JSON.parse(sendCall.input.MessageBody); - expect(messageBody.supplierSpec.supplierId).toBe("supplier1"); - expect(messageBody.supplierSpec.specId).toBe("spec1"); - }); - test("processes multiple messages in batch", async () => { const evt: SQSEvent = createSQSEvent([ createSqsRecord( @@ -504,8 +486,8 @@ describe("createSupplierAllocatorHandler", () => { const handler = createSupplierAllocatorHandler(mockedDeps); const result = await handler(evt, {} as any, {} as any); if (!result) throw new Error("expected BatchResponse, got void"); - expect(result.batchItemFailures).toHaveLength(0); - expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1); + expect(result.batchItemFailures).toHaveLength(1); + expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(2); expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ description: "Error fetching supplier from config", @@ -584,8 +566,8 @@ describe("createSupplierAllocatorHandler", () => { const result = await handler(evt, {} as any, {} as any); if (!result) throw new Error("expected BatchResponse, got void"); - expect(result.batchItemFailures).toHaveLength(0); - expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1); + expect(result.batchItemFailures).toHaveLength(1); + expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(2); expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ description: "Error fetching supplier from config", diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 4eddc981f..35a9a6586 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -45,7 +45,7 @@ function validateType(event: unknown) { async function getSupplierFromConfig( letterEvent: PreparedEvents, deps: Deps, -): Promise { +): Promise { try { const letterVariant: LetterVariant = await getVariantDetails( letterEvent.data.letterVariantId, @@ -113,12 +113,12 @@ async function getSupplierFromConfig( }; return supplierDetails; } catch (error) { - deps.logger.info({ + deps.logger.error({ description: "Error fetching supplier from config", err: error, variantId: letterEvent.data.letterVariantId, }); - return undefined; + throw error; } } @@ -216,6 +216,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { letterEvent as PreparedEvents, deps, ); + const supplierSpec = supplierDetails?.supplierSpec; deps.logger.info({ description: "Resolved supplier details from config", @@ -224,8 +225,8 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { incrementAllocation( volumeGroupAllocations, - supplierDetails?.volumeGroupId ?? "unknown", - supplierDetails?.supplierSpec?.supplierId ?? "unknown", + supplierDetails.volumeGroupId, + supplierDetails?.supplierSpec.supplierId, 1, deps, ); From e0a8d26adac7c3d6b6f092fe495ffda9ae8e559a Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 28 Apr 2026 13:46:34 +0100 Subject: [PATCH 23/25] pre review tidy --- .../terraform/components/api/README.md | 1 - .../api/ddb_table_supplier_quotas.tf | 15 ----------- .../terraform/components/api/variables.tf | 25 ------------------- .../supplier-quotas-repository.test.ts | 6 +++-- .../src/supplier-quotas-repository.ts | 13 +++++----- .../src/handler/allocate-handler.ts | 3 +-- 6 files changed, 11 insertions(+), 52 deletions(-) diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 72b976bf0..ce859ec31 100644 --- a/infrastructure/terraform/components/api/README.md +++ b/infrastructure/terraform/components/api/README.md @@ -37,7 +37,6 @@ No requirements. | [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | | [letter\_event\_source](#input\_letter\_event\_source) | Source value to use for the letter status event updates | `string` | `"/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status"` | no | | [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no | -| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string, priority = number, billingId = string }))` |
{
"client1-aspiring": {
"billingId": "client1-aspiring-billing",
"priority": "0",
"specId": "client1-aspiring",
"supplierId": "supplier1"
},
"client1-dmapp": {
"billingId": "notify-admail-billing",
"priority": "1",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"client1-globalminds": {
"billingId": "client1-globalminds-billing",
"priority": "2",
"specId": "client1-globalminds",
"supplierId": "supplier1"
},
"client1-mymelanoma": {
"billingId": "client1-mymelanoma-billing",
"priority": "3",
"specId": "client1-mymelanoma",
"supplierId": "supplier1"
},
"client1-ofh": {
"billingId": "client1-ofh-billing",
"priority": "4",
"specId": "client1-ofh",
"supplierId": "supplier1"
},
"client1-prostateprogress": {
"billingId": "client1-prostateprogress-billing",
"priority": "5",
"specId": "client1-prostateprogress",
"supplierId": "supplier1"
},
"client1-protectc": {
"billingId": "notify-c5-colour-billing",
"priority": "6",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
},
"client1-restore": {
"billingId": "client1-restore-billing",
"priority": "7",
"specId": "client1-restore",
"supplierId": "supplier1"
},
"gpreg-admail": {
"billingId": "notify-admail-billing",
"priority": "8",
"specId": "notify-admail",
"supplierId": "supplier1"
},
"nces-abnormal-results": {
"billingId": "nces-abnormal-results-billing",
"priority": "9",
"specId": "nces-abnormal-results",
"supplierId": "supplier1"
},
"nces-abnormal-results-braille": {
"billingId": "nces-abnormal-results-braille-billing",
"priority": "10",
"specId": "nces-abnormal-results-braille",
"supplierId": "supplier1"
},
"nces-invites": {
"billingId": "nces-invites-billing",
"priority": "10",
"specId": "nces-invites",
"supplierId": "supplier1"
},
"nces-invites-braille": {
"billingId": "nces-invites-braille-billing",
"priority": "10",
"specId": "nces-invites-braille",
"supplierId": "supplier1"
},
"nces-standard": {
"billingId": "notify-c5-whitemail-billing",
"priority": "11",
"specId": "notify-c5-whitemail",
"supplierId": "supplier1"
},
"nces-standard-braille": {
"billingId": "notify-braille-whitemail-billing",
"priority": "12",
"specId": "notify-braille-whitemail",
"supplierId": "supplier1"
},
"notify-braille": {
"billingId": "notify-braille-billing",
"priority": "13",
"specId": "notify-braille",
"supplierId": "supplier1"
},
"notify-digital-letters-standard": {
"billingId": "notify-c5-billing",
"priority": "97",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard": {
"billingId": "notify-c5-billing",
"priority": "98",
"specId": "notify-c5",
"supplierId": "supplier1"
},
"notify-standard-colour": {
"billingId": "notify-c5-colour-billing",
"priority": "99",
"specId": "notify-c5-colour",
"supplierId": "supplier1"
}
}
| no | | [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | | [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | | [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no | diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf index 663b27975..bf9eb3444 100644 --- a/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf +++ b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf @@ -20,21 +20,6 @@ resource "aws_dynamodb_table" "supplier-quotas" { type = "S" } - attribute { - name = "entityType" - type = "S" - } - - - - // The type-index GSI allows us to query for all supplier quotas of a given type (e.g. all supplier daily quotas) - global_secondary_index { - name = "EntityTypeIndex" - hash_key = "entityType" - range_key = "sk" - projection_type = "ALL" - } - point_in_time_recovery { enabled = true } diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf index afe841766..0c64ff283 100644 --- a/infrastructure/terraform/components/api/variables.tf +++ b/infrastructure/terraform/components/api/variables.tf @@ -135,31 +135,6 @@ variable "eventpub_control_plane_bus_arn" { default = "" } -variable "letter_variant_map" { - type = map(object({ supplierId = string, specId = string, priority = number, billingId = string })) - default = { - "client1-aspiring" = { supplierId = "supplier1", specId = "client1-aspiring", priority = "0", billingId = "client1-aspiring-billing" }, - "client1-dmapp" = { supplierId = "supplier1", specId = "notify-admail", priority = "1", billingId = "notify-admail-billing" }, - "client1-globalminds" = { supplierId = "supplier1", specId = "client1-globalminds", priority = "2", billingId = "client1-globalminds-billing" }, - "client1-mymelanoma" = { supplierId = "supplier1", specId = "client1-mymelanoma", priority = "3", billingId = "client1-mymelanoma-billing" }, - "client1-ofh" = { supplierId = "supplier1", specId = "client1-ofh", priority = "4", billingId = "client1-ofh-billing" }, - "client1-prostateprogress" = { supplierId = "supplier1", specId = "client1-prostateprogress", priority = "5", billingId = "client1-prostateprogress-billing" }, - "client1-protectc" = { supplierId = "supplier1", specId = "notify-c5-colour", priority = "6", billingId = "notify-c5-colour-billing" }, - "client1-restore" = { supplierId = "supplier1", specId = "client1-restore", priority = "7", billingId = "client1-restore-billing" }, - "gpreg-admail" = { supplierId = "supplier1", specId = "notify-admail", priority = "8", billingId = "notify-admail-billing" }, - "nces-abnormal-results" = { supplierId = "supplier1", specId = "nces-abnormal-results", priority = "9", billingId = "nces-abnormal-results-billing" }, - "nces-abnormal-results-braille" = { supplierId = "supplier1", specId = "nces-abnormal-results-braille", priority = "10", billingId = "nces-abnormal-results-braille-billing" }, - "nces-invites" = { supplierId = "supplier1", specId = "nces-invites", priority = "10", billingId = "nces-invites-billing" }, - "nces-invites-braille" = { supplierId = "supplier1", specId = "nces-invites-braille", priority = "10", billingId = "nces-invites-braille-billing" }, - "nces-standard" = { supplierId = "supplier1", specId = "notify-c5-whitemail", priority = "11", billingId = "notify-c5-whitemail-billing" }, - "nces-standard-braille" = { supplierId = "supplier1", specId = "notify-braille-whitemail", priority = "12", billingId = "notify-braille-whitemail-billing" }, - "notify-braille" = { supplierId = "supplier1", specId = "notify-braille", priority = "13", billingId = "notify-braille-billing" }, - "notify-digital-letters-standard" = { supplierId = "supplier1", specId = "notify-c5", priority = "97", billingId = "notify-c5-billing" }, - "notify-standard" = { supplierId = "supplier1", specId = "notify-c5", priority = "98", billingId = "notify-c5-billing" }, - "notify-standard-colour" = { supplierId = "supplier1", specId = "notify-c5-colour", priority = "99", billingId = "notify-c5-colour-billing" } - } -} - variable "disable_gateway_execute_endpoint" { type = bool description = "Disable the execution endpoint for the API Gateway" diff --git a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts index 4c69e93e4..38f212978 100644 --- a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts @@ -150,7 +150,8 @@ describe("SupplierQuotasRepository", () => { ); const result = await repository.getOverallAllocation(volumeGroupId); - expect(result?.allocations[supplierId]).toBe(150); + const resultMap = new Map(Object.entries(result?.allocations ?? {})); + expect(resultMap.get(supplierId)).toBe(150); }); test("getDailyAllocation returns correct allocation for existing group and date", async () => { @@ -225,6 +226,7 @@ describe("SupplierQuotasRepository", () => { await repository.updateDailyAllocation(date, supplierId, newAllocation); const result = await repository.getDailyAllocation(date); - expect(result?.allocations[supplierId]).toBe(75); + const resultMap = new Map(Object.entries(result?.allocations ?? {})); + expect(resultMap.get(supplierId)).toBe(75); }); }); diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index ca0c372f9..b0eb5afae 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -74,7 +74,9 @@ export class SupplierQuotasRepository { ): Promise { const overallAllocation = await this.getOverallAllocation(groupId); const allocations = overallAllocation?.allocations ?? {}; - const currentAllocation = allocations[supplierId] ?? 0; + const allocationsMap = new Map(Object.entries(allocations)); + const currentAllocation = allocationsMap.get(supplierId) ?? 0; + const updatedAllocation = currentAllocation + newAllocation; if (overallAllocation) { @@ -107,7 +109,6 @@ export class SupplierQuotasRepository { } async getDailyAllocation(date: string): Promise { - console.log("Getting daily allocation for date:", date); const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierQuotasTableName, @@ -118,7 +119,6 @@ export class SupplierQuotasRepository { }), ); if (!result.Item) { - console.log("No daily allocation found for date:", date); return undefined; } // Strip DynamoDB keys before parsing @@ -129,8 +129,7 @@ export class SupplierQuotasRepository { async putDailyAllocation(allocation: DailyAllocation): Promise { const parsedAllocation = $DailyAllocation.parse(allocation); - console.log("Putting daily allocation:", parsedAllocation); - const output = await this.ddbClient.send( + await this.ddbClient.send( new PutCommand({ TableName: this.config.supplierQuotasTableName, Item: ItemForRecord( @@ -140,7 +139,6 @@ export class SupplierQuotasRepository { ), }), ); - console.log("PutDailyAllocation output:", output); } async updateDailyAllocation( @@ -150,7 +148,8 @@ export class SupplierQuotasRepository { ): Promise { const dailyAllocation = await this.getDailyAllocation(date); const allocations = dailyAllocation?.allocations ?? {}; - const currentAllocation = allocations[supplierId] ?? 0; + const allocationsMap = new Map(Object.entries(allocations)); + const currentAllocation = allocationsMap.get(supplierId) ?? 0; const updatedAllocation = currentAllocation + newAllocation; if (dailyAllocation) { diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 35a9a6586..3031da162 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -196,8 +196,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { const batchItemFailures: SQSBatchItemFailure[] = []; const perAllocationSuccess: AllocationMetrics = new Map(); const perAllocationFailure: AllocationMetrics = new Map(); - const volumeGroupAllocations: VolumeGroupAllocation = new Map(); // Map of volume group id to supplier allocations for that group, used to track the allocations calculated in this batch for emitting metrics and updating the quotas after processing the batch - // Initialise the supplier quotas. + const volumeGroupAllocations: VolumeGroupAllocation = new Map(); const tasks = event.Records.map(async (record) => { let supplier = "unknown"; From e4aad0d17b9b20d86c151002aedbcdfb9d41c435 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 30 Apr 2026 11:35:23 +0100 Subject: [PATCH 24/25] review changes --- .../api/module_lambda_supplier_allocator.tf | 19 +++++-- lambdas/supplier-allocator/package.json | 2 + .../__tests__/allocate-handler.test.ts | 52 +++++++++++++++++++ .../__tests__/allocation-config.test.ts | 10 ++++ .../src/handler/allocate-handler.ts | 21 +++++--- .../src/handler/allocation-config.ts | 12 ++++- package-lock.json | 21 ++++++++ 7 files changed, 127 insertions(+), 10 deletions(-) diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index 080b44ca4..7bb3595fb 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -83,7 +83,22 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { } statement { - sid = "AllowDynamoDBAccess" + sid = "AllowConfigDynamoDBAccess" + effect = "Allow" + + actions = [ + "dynamodb:GetItem", + "dynamodb:Query", + ] + + resources = [ + aws_dynamodb_table.supplier-configuration.arn, + "${aws_dynamodb_table.supplier-configuration.arn}/index/*", + ] + } + + statement { + sid = "AllowQuotaDynamoDBAccess" effect = "Allow" actions = [ @@ -94,9 +109,7 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { ] resources = [ - aws_dynamodb_table.supplier-configuration.arn, aws_dynamodb_table.supplier-quotas.arn, - "${aws_dynamodb_table.supplier-configuration.arn}/index/*", "${aws_dynamodb_table.supplier-quotas.arn}/index/*" ] } diff --git a/lambdas/supplier-allocator/package.json b/lambdas/supplier-allocator/package.json index eae1a896a..1ce379161 100644 --- a/lambdas/supplier-allocator/package.json +++ b/lambdas/supplier-allocator/package.json @@ -12,6 +12,8 @@ "@types/aws-lambda": "^8.10.148", "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", + "date-fns": "^3.0.0", + "date-fns-tz": "^3.0.0", "esbuild": "^0.27.2", "pino": "^9.7.0", "zod": "^4.1.11" diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 7362cfc2d..1dfce9f1b 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -577,6 +577,58 @@ describe("createSupplierAllocatorHandler", () => { }, ); + test("returns batch failure when no suppliers are found for pack specification", async () => { + const preparedEvent = createPreparedV2Event(); + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg1", JSON.stringify(preparedEvent)), + ]); + + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + + setupDefaultMocks(); + (allocationConfig.suppliersWithValidPack as jest.Mock).mockResolvedValue( + [], + ); + + const handler = createSupplierAllocatorHandler(mockedDeps); + const result = await handler(evt, {} as any, {} as any); + if (!result) throw new Error("expected BatchResponse, got void"); + + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); + expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(2); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( + expect.objectContaining({ + description: "Error fetching supplier from config", + err: new Error("No suppliers found for pack specification spec1"), + variantId: "lv1", + }), + ); + }); + + test("does not call selectSupplierByFactor for suppliers with capacity when there are no suppliers with capacity", async () => { + setupDefaultMocks(); + ( + allocationConfig.filterSuppliersWithCapacity as jest.Mock + ).mockResolvedValue([]); + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg1", JSON.stringify(createPreparedV2Event())), + ]); + + const handler = createSupplierAllocatorHandler(mockedDeps); + const result = await handler(evt, {} as any, {} as any); + if (!result) throw new Error("expected BatchResponse, got void"); + + expect(result.batchItemFailures).toHaveLength(0); + expect(allocationConfig.selectSupplierByFactor).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + mockedDeps, + ); + }); + test("falls back to the second selectSupplierByFactor call when the first returns undefined", async () => { setupDefaultMocks(); (allocationConfig.selectSupplierByFactor as jest.Mock) diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts index 5d6f02079..168883c68 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts @@ -961,4 +961,14 @@ describe("selectSupplierByFactor", () => { expect(result).toBe("supplier-5"); }); + + it("should throw an error if no supplier factors are returned", async () => { + ( + supplierQuotasService.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue([]); + + await expect( + selectSupplierByFactor(mockSuppliers, mockSupplierAllocations, mockDeps), + ).rejects.toThrow("No supplier factors could be calculated for allocation"); + }); }); diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 3031da162..dbd7c91e0 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -73,16 +73,25 @@ async function getSupplierFromConfig( deps, ); + if (allSuppliersForPack.length === 0) { + throw new Error( + `No suppliers found for pack specification ${preferredPack.id}`, + ); + } + const suppliersForPackWithCapacity: Supplier[] = await filterSuppliersWithCapacity(allSuppliersForPack, deps); - // selected supplier id is determined by first calling selectSupplierByFactor for suppliers with capacity and if nothing is returned tryong again with all suppliers for pack + // selected supplier id is determined by first calling selectSupplierByFactor for suppliers with capacity + // and if that returns nothing, try again with all suppliers for the pack const selectedSupplierId = - (await selectSupplierByFactor( - suppliersForPackWithCapacity, - supplierAllocations, - deps, - )) ?? + (suppliersForPackWithCapacity.length > 0 + ? await selectSupplierByFactor( + suppliersForPackWithCapacity, + supplierAllocations, + deps, + ) + : undefined) ?? (await selectSupplierByFactor( allSuppliersForPack, supplierAllocations, diff --git a/lambdas/supplier-allocator/src/handler/allocation-config.ts b/lambdas/supplier-allocator/src/handler/allocation-config.ts index cdb63d5fb..2cce7cba1 100644 --- a/lambdas/supplier-allocator/src/handler/allocation-config.ts +++ b/lambdas/supplier-allocator/src/handler/allocation-config.ts @@ -5,6 +5,8 @@ import { SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { format } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { filterPacksForLetter, getPackSpecification, @@ -14,6 +16,7 @@ import { getSupplierPacks, } from "../services/supplier-config"; import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; + import { Deps } from "../config/deps"; import { PreparedEvents } from "./types"; @@ -79,7 +82,10 @@ export async function filterSuppliersWithCapacity( suppliers: Supplier[], deps: Deps, ): Promise { - const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format + const dailyAllocationDate = format( + toZonedTime(new Date(), "Europe/London"), + "yyyy-MM-dd", + ); // Get current date in YYYY-MM-DD format (London timezone) const dailyAllocation = await deps.supplierQuotasRepo.getDailyAllocation(dailyAllocationDate); if (dailyAllocation) { @@ -103,6 +109,10 @@ export async function selectSupplierByFactor( const supplierFactors: { supplierId: string; factor: number }[] = await calculateSupplierAllocatedFactor(supplierAllocationsForPack, deps); + if (supplierFactors.length === 0) { + throw new Error("No supplier factors could be calculated for allocation"); + } + deps.logger.info({ description: "Calculated supplier factors for allocation", supplierFactors, diff --git a/package-lock.json b/package-lock.json index d8d1893b8..a31058dc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -263,6 +263,8 @@ "@types/aws-lambda": "^8.10.148", "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", + "date-fns": "^3.0.0", + "date-fns-tz": "^3.0.0", "esbuild": "^0.27.2", "pino": "^9.7.0", "zod": "^4.1.11" @@ -11158,6 +11160,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/dateformat": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.2.tgz", From 840ee68055d11674f032a0dabc082948beb44ec5 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 5 May 2026 13:40:50 +0100 Subject: [PATCH 25/25] handle concurrent updates and inserts --- .../supplier-quotas-repository.test.ts | 237 ++++++++++++++++-- .../src/supplier-quotas-repository.ts | 222 +++++++++------- .../__tests__/allocate-handler.test.ts | 2 - .../__tests__/supplier-quotas.test.ts | 97 ------- .../src/services/supplier-quotas.ts | 67 ++--- 5 files changed, 358 insertions(+), 267 deletions(-) diff --git a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts index 38f212978..3063e81ca 100644 --- a/internal/datastore/src/__test__/supplier-quotas-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts @@ -42,6 +42,12 @@ jest.setTimeout(30_000); describe("SupplierQuotasRepository", () => { let dbContext: DBContext; let repository: SupplierQuotasRepository; + let mockDdbClient: { + send: jest.Mock; + config: any; + destroy: jest.Mock; + middlewareStack: any; + }; // Database tests can take longer, especially with setup and teardown beforeAll(async () => { @@ -53,6 +59,19 @@ describe("SupplierQuotasRepository", () => { repository = new SupplierQuotasRepository(dbContext.docClient, { supplierQuotasTableName: dbContext.config.supplierQuotasTableName, }); + // Initialize mockDdbClient for tests that need it + mockDdbClient = { + send: jest.fn(), + config: {}, + destroy: jest.fn(), + middlewareStack: { + clone: jest.fn(), + use: jest.fn(), + remove: jest.fn(), + removeByTag: jest.fn(), + concat: jest.fn(), + }, + }; }); afterEach(async () => { @@ -95,19 +114,6 @@ describe("SupplierQuotasRepository", () => { expect(result).toBeUndefined(); }); - test("putOverallAllocation stores allocation correctly", async () => { - const allocation = { - id: "group-123", - volumeGroup: "group-123", - allocations: { supplier1: 100, supplier2: 200 }, - }; - - await repository.putOverallAllocation(allocation); - - const result = await repository.getOverallAllocation("group-123"); - expect(result).toEqual(allocation); - }); - test("updateOverallAllocation creates new allocation when none exists", async () => { const volumeGroupId = "group-123"; const supplierId = "supplier-123"; @@ -154,6 +160,102 @@ describe("SupplierQuotasRepository", () => { expect(resultMap.get(supplierId)).toBe(150); }); + test("updateOverallAllocation throws error for non-validation exceptions", async () => { + const volumeGroupId = "group-123"; + const supplierId = "supplier-123"; + const newAllocation = 50; + + // Mock the ddbClient to throw a generic error + mockDdbClient.send.mockRejectedValue(new Error("Generic error")); + + const repoWithMockedClient = new SupplierQuotasRepository(mockDdbClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + + await expect( + repoWithMockedClient.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ), + ).rejects.toThrow("Generic error"); + }); + + test("updateOverallAllocation calls increment twice if Putcommand fails with ConditionalCheckFailedException", async () => { + const volumeGroupId = "group-123"; + const supplierId = "supplier-123"; + const newAllocation = 50; + + // Mock the ddbClient to throw a validation e first call and throw ConditionalCheckFailedException on the second call and then succeed on the third call + mockDdbClient.send + .mockRejectedValueOnce( + Object.assign(new Error("Validation error"), { + name: "ValidationException", + }), + ) + .mockRejectedValueOnce( + Object.assign(new Error("ConditionalCheckFailedException"), { + name: "ConditionalCheckFailedException", + }), + ) + .mockResolvedValue({}); // Succeed on the third call + + const repoWithMockedClient = new SupplierQuotasRepository(mockDdbClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + + await repoWithMockedClient.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + + expect(mockDdbClient.send).toHaveBeenCalledTimes(3); + expect(mockDdbClient.send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: expect.objectContaining({ + UpdateExpression: expect.stringContaining("SET"), + }), + }), + ); + expect(mockDdbClient.send).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + input: expect.objectContaining({ + UpdateExpression: expect.stringContaining("SET"), + }), + }), + ); + }); + + test("updateOverallAllocation throw exception if Putcommand fails with any error other than ConditionalCheckFailedException", async () => { + const volumeGroupId = "group-123"; + const supplierId = "supplier-123"; + const newAllocation = 50; + + // Mock the ddbClient to throw a generic error + mockDdbClient.send + .mockRejectedValueOnce( + Object.assign(new Error("Validation error"), { + name: "ValidationException", + }), + ) + .mockRejectedValueOnce(new Error("Generic error")); // Throw a generic error on the second call + + const repoWithMockedClient = new SupplierQuotasRepository(mockDdbClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + + await expect( + repoWithMockedClient.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ), + ).rejects.toThrow("Generic error"); + }); + test("getDailyAllocation returns correct allocation for existing group and date", async () => { const allocationId = "daily-allocation-123"; const date = "2023-10-01"; @@ -182,19 +284,6 @@ describe("SupplierQuotasRepository", () => { expect(result).toBeUndefined(); }); - test("putDailyAllocation stores allocation correctly", async () => { - const allocation = { - id: "daily-allocation-123", - date: "2023-10-01", - allocations: { supplier1: 50, supplier2: 75 }, - }; - - await repository.putDailyAllocation(allocation); - - const result = await repository.getDailyAllocation("2023-10-01"); - expect(result).toEqual(allocation); - }); - test("updateDailyAllocation creates new allocation when none exists", async () => { const date = "2023-10-01"; const supplierId = "supplier-123"; @@ -229,4 +318,100 @@ describe("SupplierQuotasRepository", () => { const resultMap = new Map(Object.entries(result?.allocations ?? {})); expect(resultMap.get(supplierId)).toBe(75); }); + + test("updateDailyAllocation throws error for non-validation exceptions", async () => { + const date = "2023-10-01"; + const supplierId = "supplier-123"; + const newAllocation = 25; + + // Mock the ddbClient to throw a generic error + mockDdbClient.send.mockRejectedValue(new Error("Generic error")); + + const repoWithMockedClient = new SupplierQuotasRepository(mockDdbClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + + await expect( + repoWithMockedClient.updateDailyAllocation( + date, + supplierId, + newAllocation, + ), + ).rejects.toThrow("Generic error"); + }); + + test("updateDailyAllocation calls increment twice if Putcommand fails with ConditionalCheckFailedException", async () => { + const date = "2023-10-01"; + const supplierId = "supplier-123"; + const newAllocation = 25; + + // Mock the ddbClient to throw a validation e first call and throw ConditionalCheckFailedException on the second call and then succeed on the third call + mockDdbClient.send + .mockRejectedValueOnce( + Object.assign(new Error("Validation error"), { + name: "ValidationException", + }), + ) + .mockRejectedValueOnce( + Object.assign(new Error("ConditionalCheckFailedException"), { + name: "ConditionalCheckFailedException", + }), + ) + .mockResolvedValue({}); // Succeed on the third call + + const repoWithMockedClient = new SupplierQuotasRepository(mockDdbClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + + await repoWithMockedClient.updateDailyAllocation( + date, + supplierId, + newAllocation, + ); + + expect(mockDdbClient.send).toHaveBeenCalledTimes(3); + expect(mockDdbClient.send).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + input: expect.objectContaining({ + UpdateExpression: expect.stringContaining("SET"), + }), + }), + ); + expect(mockDdbClient.send).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + input: expect.objectContaining({ + UpdateExpression: expect.stringContaining("SET"), + }), + }), + ); + }); + + test("updateDailyAllocation throw exception if Putcommand fails with any error other than ConditionalCheckFailedException", async () => { + const date = "2023-10-01"; + const supplierId = "supplier-123"; + const newAllocation = 25; + + // Mock the ddbClient to throw a generic error + mockDdbClient.send + .mockRejectedValueOnce( + Object.assign(new Error("Validation error"), { + name: "ValidationException", + }), + ) + .mockRejectedValueOnce(new Error("Generic error")); // Throw a generic error on the second call + + const repoWithMockedClient = new SupplierQuotasRepository(mockDdbClient, { + supplierQuotasTableName: dbContext.config.supplierQuotasTableName, + }); + + await expect( + repoWithMockedClient.updateDailyAllocation( + date, + supplierId, + newAllocation, + ), + ).rejects.toThrow("Generic error"); + }); }); diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index b0eb5afae..89383e0f6 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -15,18 +15,6 @@ export type SupplierQuotasRepositoryConfig = { supplierQuotasTableName: string; }; -function ItemForRecord( - entity: string, - id: string, - record: Record, -): Record { - return { - pk: `ENTITY#${entity}`, - sk: `ID#${id}`, - ...record, - }; -} - export class SupplierQuotasRepository { constructor( readonly ddbClient: DynamoDBDocumentClient, @@ -51,61 +39,89 @@ export class SupplierQuotasRepository { return $OverallAllocation.parse(item); } - async putOverallAllocation(allocation: OverallAllocation): Promise { - const parsedAllocation = $OverallAllocation.parse(allocation); - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "overall-allocation", - allocation.id, - parsedAllocation, - ), - }), + private static isValidationException(err: unknown): boolean { + return err instanceof Error && err.name === "ValidationException"; + } + + private static isConditionalCheckFailed(err: unknown): boolean { + return ( + err instanceof Error && err.name === "ConditionalCheckFailedException" ); } - // Update the overallAllocation table updating the allocations array for a given volume group - // or adding the value if the supplier is not present // async updateOverallAllocation( groupId: string, supplierId: string, newAllocation: number, ): Promise { - const overallAllocation = await this.getOverallAllocation(groupId); - const allocations = overallAllocation?.allocations ?? {}; - const allocationsMap = new Map(Object.entries(allocations)); - const currentAllocation = allocationsMap.get(supplierId) ?? 0; - - const updatedAllocation = currentAllocation + newAllocation; - - if (overallAllocation) { - // Update existing allocation - const updatedAllocations = { - ...allocations, - [supplierId]: updatedAllocation, - }; + const now = new Date().toISOString(); + + const key = { + pk: "ENTITY#overall-allocation", + sk: `ID#${groupId}`, + }; + + const increment = async () => { await this.ddbClient.send( new UpdateCommand({ TableName: this.config.supplierQuotasTableName, - Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, - UpdateExpression: - "SET allocations = :allocations, updatedAt = :updatedAt", + Key: key, + UpdateExpression: ` + SET + allocations.#supplierId = if_not_exists(allocations.#supplierId, :zero) + :delta, + id = if_not_exists(id, :groupId), + volumeGroup = if_not_exists(volumeGroup, :groupId), + createdAt = if_not_exists(createdAt, :now), + updatedAt = :now + `, + ExpressionAttributeNames: { + "#supplierId": supplierId, + }, ExpressionAttributeValues: { - ":allocations": updatedAllocations, - ":updatedAt": new Date().toISOString(), + ":zero": 0, + ":delta": newAllocation, + ":groupId": groupId, + ":now": now, + }, + }), + ); + }; + + try { + await increment(); + return; + } catch (error) { + if (!SupplierQuotasRepository.isValidationException(error)) { + throw error; + } + } + + try { + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: { + ...key, + id: groupId, + volumeGroup: groupId, + allocations: { + [supplierId]: newAllocation, + }, + createdAt: now, + updatedAt: now, }, + ConditionExpression: "attribute_not_exists(pk)", }), ); - } else { - // Create new allocation - const newOverallAllocation: OverallAllocation = { - id: groupId, - volumeGroup: groupId, - allocations: { [supplierId]: updatedAllocation }, - }; - await this.putOverallAllocation(newOverallAllocation); + return; + } catch (error) { + if (!SupplierQuotasRepository.isConditionalCheckFailed(error)) { + throw error; + } } + + // Another writer created the item first; retry the atomic increment. + await increment(); } async getDailyAllocation(date: string): Promise { @@ -127,60 +143,80 @@ export class SupplierQuotasRepository { return $DailyAllocation.parse(item); } - async putDailyAllocation(allocation: DailyAllocation): Promise { - const parsedAllocation = $DailyAllocation.parse(allocation); - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "daily-allocation", - allocation.date, - parsedAllocation, - ), - }), - ); - } - async updateDailyAllocation( - date: string, + allocationDate: string, supplierId: string, newAllocation: number, ): Promise { - const dailyAllocation = await this.getDailyAllocation(date); - const allocations = dailyAllocation?.allocations ?? {}; - const allocationsMap = new Map(Object.entries(allocations)); - const currentAllocation = allocationsMap.get(supplierId) ?? 0; - const updatedAllocation = currentAllocation + newAllocation; - - if (dailyAllocation) { - // Update existing allocation - const updatedAllocations = { - ...allocations, - [supplierId]: updatedAllocation, - }; + const now = new Date().toISOString(); + + const key = { + pk: "ENTITY#daily-allocation", + sk: `ID#${allocationDate}`, + }; + + const increment = async () => { await this.ddbClient.send( new UpdateCommand({ TableName: this.config.supplierQuotasTableName, - Key: { - pk: "ENTITY#daily-allocation", - sk: `ID#${date}`, + Key: key, + UpdateExpression: ` + SET + allocations.#supplierId = if_not_exists(allocations.#supplierId, :zero) + :delta, + id = if_not_exists(id, :id), + #date = if_not_exists(#date, :date), + createdAt = if_not_exists(createdAt, :now), + updatedAt = :now + `, + ExpressionAttributeNames: { + "#supplierId": supplierId, + "#date": "date", }, - UpdateExpression: - "SET allocations = :allocations, updatedAt = :updatedAt", ExpressionAttributeValues: { - ":allocations": updatedAllocations, - ":updatedAt": new Date().toISOString(), + ":zero": 0, + ":delta": newAllocation, + ":id": `ID#${allocationDate}`, + ":date": allocationDate, + ":now": now, }, }), ); - } else { - // Create new allocation - const newDailyAllocation: DailyAllocation = { - id: `ID#${date}`, - date, - allocations: { [supplierId]: updatedAllocation }, - }; - await this.putDailyAllocation(newDailyAllocation); + }; + + try { + await increment(); + return; + } catch (error) { + if (!SupplierQuotasRepository.isValidationException(error)) { + throw error; + } } + + try { + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: { + ...key, + id: `ID#${allocationDate}`, + date: allocationDate, + allocations: { + [supplierId]: newAllocation, + }, + createdAt: now, + updatedAt: now, + }, + ConditionExpression: "attribute_not_exists(pk)", + }), + ); + return; + } catch (error) { + if (!SupplierQuotasRepository.isConditionalCheckFailed(error)) { + throw error; + } + } + + // Another request created the item first, so retry the atomic increment. + await increment(); } } diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 1dfce9f1b..201447cce 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -210,10 +210,8 @@ describe("createSupplierAllocatorHandler", () => { ddbClient: {} as any, config: {} as any, getOverallAllocation: jest.fn(), - putOverallAllocation: jest.fn(), updateOverallAllocation: jest.fn(), getDailyAllocation: jest.fn(), - putDailyAllocation: jest.fn(), updateDailyAllocation: jest.fn(), } as jest.Mocked; diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts index ef37971b8..cbcb08e89 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts @@ -14,10 +14,8 @@ describe("supplier-quotas", () => { supplierQuotasRepo: { getOverallAllocation: jest.fn(), updateOverallAllocation: jest.fn(), - putOverallAllocation: jest.fn(), getDailyAllocation: jest.fn(), updateDailyAllocation: jest.fn(), - putDailyAllocation: jest.fn(), } as any, logger: { info: jest.fn(), @@ -216,100 +214,5 @@ describe("supplier-quotas", () => { mockDeps.supplierQuotasRepo.updateDailyAllocation, ).toHaveBeenCalledWith("2024-01-15", "supplier1", 150); }); - - it("should create new overall allocation when none exists", async () => { - ( - mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock - ).mockResolvedValue(null); - ( - mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock - ).mockResolvedValue(null); - - await updateSupplierAllocation("vg1", "supplier1", 100, mockDeps); - - expect( - mockDeps.supplierQuotasRepo.putOverallAllocation, - ).toHaveBeenCalledWith({ - id: "vg1", - volumeGroup: "vg1", - allocations: { - supplier1: 100, - }, - }); - }); - - it("should create new daily allocation when none exists", async () => { - const existingOverallAllocation: OverallAllocation = { - id: "vg1", - volumeGroup: "vg1", - allocations: { - supplier1: 100, - }, - }; - - ( - mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock - ).mockResolvedValue(existingOverallAllocation); - ( - mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock - ).mockResolvedValue(null); - - await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); - - expect( - mockDeps.supplierQuotasRepo.putDailyAllocation, - ).toHaveBeenCalledWith({ - id: "ID#2024-01-15", - date: "2024-01-15", - allocations: { - supplier1: 150, - }, - }); - }); - - it("should log when updating existing overall allocation", async () => { - const existingOverallAllocation: OverallAllocation = { - id: "vg1", - volumeGroup: "vg1", - allocations: { - supplier1: 100, - }, - }; - - ( - mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock - ).mockResolvedValue(existingOverallAllocation); - ( - mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock - ).mockResolvedValue(null); - - await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); - - expect(mockDeps.logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - description: "Existing overall allocation found for volume group", - volumeGroupId: "vg1", - }), - ); - }); - - it("should log when creating new overall allocation", async () => { - ( - mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock - ).mockResolvedValue(null); - ( - mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock - ).mockResolvedValue(null); - - await updateSupplierAllocation("vg1", "supplier1", 100, mockDeps); - - expect(mockDeps.logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - description: - "No overall allocation found for volume group, creating new one", - volumeGroupId: "vg1", - }), - ); - }); }); }); diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index 6ab9ba12c..00a0c1b46 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -1,5 +1,6 @@ -import { DailyAllocation, OverallAllocation } from "@internal/datastore"; import { SupplierAllocation } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { format } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { Deps } from "../config/deps"; export async function calculateSupplierAllocatedFactor( @@ -42,52 +43,20 @@ export async function updateSupplierAllocation( newAllocation: number, deps: Deps, ): Promise { - const overallAllocation = - await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); - if (overallAllocation) { - deps.logger.info({ - description: "Existing overall allocation found for volume group", - volumeGroupId, - overallAllocation, - }); - await deps.supplierQuotasRepo.updateOverallAllocation( - volumeGroupId, - supplierId, - newAllocation, - ); - } else { - const newOverallAllocation: OverallAllocation = { - id: volumeGroupId, - volumeGroup: volumeGroupId, - allocations: { - [supplierId]: newAllocation, - }, - }; - deps.logger.info({ - description: - "No overall allocation found for volume group, creating new one", - volumeGroupId, - newOverallAllocation, - }); - await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); - } - const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format - const dailyAllocation = - await deps.supplierQuotasRepo.getDailyAllocation(dailyAllocationDate); - if (dailyAllocation) { - await deps.supplierQuotasRepo.updateDailyAllocation( - dailyAllocationDate, - supplierId, - newAllocation, - ); - } else { - const newDailyAllocation: DailyAllocation = { - id: `ID#${dailyAllocationDate}`, - date: dailyAllocationDate, - allocations: { - [supplierId]: newAllocation, - }, - }; - await deps.supplierQuotasRepo.putDailyAllocation(newDailyAllocation); - } + await deps.supplierQuotasRepo.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + + const dailyAllocationDate = format( + toZonedTime(new Date(), "Europe/London"), + "yyyy-MM-dd", + ); + + await deps.supplierQuotasRepo.updateDailyAllocation( + dailyAllocationDate, + supplierId, + newAllocation, + ); }