From e80a56bf855169cfeab655670c6420c3c3326489 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 19 Mar 2026 15:34:59 +0000 Subject: [PATCH 01/23] 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 e268e53c221cd08203c7846c6348d11619cffea4 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 7 Apr 2026 10:40:45 +0100 Subject: [PATCH 02/23] 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 b196a8274998c91a8776139461ff209d2da22685 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 14 Apr 2026 14:33:34 +0100 Subject: [PATCH 03/23] 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 82b548b39c701cc82bb3a5c23f6dac49ca508b34 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 16 Apr 2026 09:19:13 +0100 Subject: [PATCH 04/23] 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 df238b8d5e0ff712ff4405268893cde50dbc6b90 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 16 Apr 2026 16:02:28 +0100 Subject: [PATCH 05/23] 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 bde6832acf7527802c66b28ae3aa43fcc63aec85 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 10:33:15 +0100 Subject: [PATCH 06/23] 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 092973a75af850f38857af8d7180294f6acdc653 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 11:00:08 +0100 Subject: [PATCH 07/23] 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 066ba3c13e90fe3989f0db1e943f4e4193fc35ec Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 14:25:03 +0100 Subject: [PATCH 08/23] 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 9cff58fc1bcc0430d2f693f2ed9000a74e2505d1 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 15:01:17 +0100 Subject: [PATCH 09/23] 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 7b7c2e82f968ebbcce2ee1382c671ed9338be88a Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 15:39:40 +0100 Subject: [PATCH 10/23] 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 491c77ceab9f67243a0cb145452963b86cf414c1 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 16:13:31 +0100 Subject: [PATCH 11/23] 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 d2cff1b6864ad9650f8ecb40d7ce8677a336fe59 Mon Sep 17 00:00:00 2001 From: David Wass Date: Mon, 20 Apr 2026 09:28:14 +0100 Subject: [PATCH 12/23] 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 a24e30250b6d620d0eba72f93338697998462656 Mon Sep 17 00:00:00 2001 From: David Wass Date: Mon, 20 Apr 2026 10:07:56 +0100 Subject: [PATCH 13/23] 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 3db8de2ef8d9a1f8ca0d8c838e4ae91deaf9cd17 Mon Sep 17 00:00:00 2001 From: David Wass Date: Mon, 20 Apr 2026 10:30:17 +0100 Subject: [PATCH 14/23] 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 12c49852be6f6ebd44f81b577a5c1bc4a7a0d3b2 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 21 Apr 2026 11:19:32 +0100 Subject: [PATCH 15/23] 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 32d4e293e..8a2c410dd 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", @@ -5100,15 +5118,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 c59ea2647e200f861950adcb9d9b5f35873f3d8c Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 21 Apr 2026 11:46:35 +0100 Subject: [PATCH 16/23] 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 f4119868b160555d0e37a785287192a77836a1f3 Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 22 Apr 2026 10:37:36 +0100 Subject: [PATCH 17/23] 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 5eef2417f58add65856a1fb654cb9536c75f7d0c Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 22 Apr 2026 14:21:57 +0100 Subject: [PATCH 18/23] 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 88d90f32a3b669a8a7c14e0325293480d223b113 Mon Sep 17 00:00:00 2001 From: David Wass Date: Wed, 22 Apr 2026 14:55:05 +0100 Subject: [PATCH 19/23] 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 b6e69a43607504aae95c4dd6ab8e934970d37edb Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 23 Apr 2026 14:59:27 +0100 Subject: [PATCH 20/23] 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 4e25922f4c274423faaffc976af82ccba5ce60d5 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 23 Apr 2026 15:36:42 +0100 Subject: [PATCH 21/23] 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 | 4 +- .../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, 1851 insertions(+), 193 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 97653bcac..29cab24d1 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 201e10186..0e71bb867 100644 --- a/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf +++ b/infrastructure/terraform/components/api/module_lambda_upsert_letter.tf @@ -34,9 +34,7 @@ module "upsert_letter" { log_destination_arn = local.destination_arn 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) - }) + lambda_env_vars = local.common_lambda_env_vars } data "aws_iam_policy_document" "upsert_letter_lambda" { 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 5862cca20..42f9a3c98 100644 --- a/tests/helpers/event-fixtures.ts +++ b/tests/helpers/event-fixtures.ts @@ -18,7 +18,7 @@ export function createPreparedV1Event(overrides: Record = {}) { (overrides.domainId as string) ?? "fe658e11-0ffc-44f4-8ad6-0fafe75bfeee", letterVariantId: - (overrides.letterVariantId as string) ?? "digitrials-aspiring", + (overrides.letterVariantId as string) ?? "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 50471cbd8144d302599935d0c691d5ff86db2092 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 23 Apr 2026 16:11:49 +0100 Subject: [PATCH 22/23] 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 290636122766261553d54ae1c672b25266e98b5a Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 28 Apr 2026 13:46:34 +0100 Subject: [PATCH 23/23] 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 29cab24d1..03bb02049 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";