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 dda16237b..f69b59360 100644 --- a/config/suppliers/letter-variant/notify-standard-test1.json +++ b/config/suppliers/letter-variant/notify-standard-test1.json @@ -21,9 +21,9 @@ "id": "notify-standard-test1", "name": "Dev Happy Path", "packSpecificationIds": [ - "notify-c5", - "notify-c4" + "notify-c5" ], + "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/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" +} diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md index 97653bcac..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 }))` |
{
"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 | | [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_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf index f751e2ef4..2271ce7ed 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/ddb_table_supplier_quotas.tf b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf new file mode 100644 index 000000000..bf9eb3444 --- /dev/null +++ b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf @@ -0,0 +1,34 @@ +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" + } + + 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 b568307c9..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 }) } @@ -89,13 +88,16 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { actions = [ "dynamodb:GetItem", - "dynamodb:Query" + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:UpdateItem" ] resources = [ aws_dynamodb_table.supplier-configuration.arn, - "${aws_dynamodb_table.supplier-configuration.arn}/index/volumeGroup-index" - + aws_dynamodb_table.supplier-quotas.arn, + "${aws_dynamodb_table.supplier-configuration.arn}/index/*", + "${aws_dynamodb_table.supplier-quotas.arn}/index/*" ] } } 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..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 = { - "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" }, - "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/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/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 9d0bf0e1e..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 { @@ -165,11 +166,35 @@ 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" }, + ], +}); + +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" }, ], }); @@ -183,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) { @@ -194,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-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index ddd44fd4d..6648b7fb6 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: "ENTITY#supplier-pack", + sk: `ID#${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: "ENTITY#pack-specification", + sk: `ID#${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/__test__/supplier-quotas-repository.test.ts b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts new file mode 100644 index 000000000..38f212978 --- /dev/null +++ b/internal/datastore/src/__test__/supplier-quotas-repository.test.ts @@ -0,0 +1,232 @@ +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, + date: string, + allocations: Record, +) { + return { + pk: "ENTITY#daily-allocation", + sk: `ID#${date}`, + id: allocationId, + 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); + 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 () => { + const allocationId = "daily-allocation-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, date, allocations), + }), + ); + + const result = await repository.getDailyAllocation(date); + + expect(result).toEqual({ + id: allocationId, + date, + allocations, + }); + }); + + test("getDailyAllocation returns undefined for non-existent date", async () => { + const date = "2023-09-01"; + + const result = await repository.getDailyAllocation(date); + + expect(result).toBeUndefined(); + }); + + test("putDailyAllocation stores allocation correctly", async () => { + const allocation = { + id: "daily-allocation-123", + date: "2023-10-01", + allocations: { supplier1: 50, supplier2: 75 }, + }; + + await repository.putDailyAllocation(allocation); + + const result = await repository.getDailyAllocation("2023-10-01"); + expect(result).toEqual(allocation); + }); + + test("updateDailyAllocation creates new allocation when none exists", async () => { + const date = "2023-10-01"; + const supplierId = "supplier-123"; + const newAllocation = 25; + + await repository.updateDailyAllocation(date, supplierId, newAllocation); + + const result = await repository.getDailyAllocation(date); + expect(result).toEqual({ + id: `ID#${date}`, + date, + allocations: { [supplierId]: newAllocation }, + }); + }); + + test("updateDailyAllocation updates existing allocation", async () => { + const allocationId = "daily-allocation-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, date, initialAllocations), + }), + ); + + const newAllocation = 25; + await repository.updateDailyAllocation(date, supplierId, newAllocation); + + const result = await repository.getDailyAllocation(date); + const resultMap = new Map(Object.entries(result?.allocations ?? {})); + expect(resultMap.get(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/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 4eeeddb10..46794c0c1 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": "ENTITY#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: "ENTITY#pack-specification", sk: `ID#${packSpecId}` }, + }), + ); + if (!result.Item) { + throw new Error(`No pack specification found for id ${packSpecId}`); + } + return $PackSpecification.parse(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..b0eb5afae --- /dev/null +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -0,0 +1,186 @@ +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) { + return undefined; + } + // 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 { + 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 + // or adding the value if the supplier is not present // + async updateOverallAllocation( + groupId: string, + supplierId: string, + newAllocation: number, + ): Promise { + const overallAllocation = await this.getOverallAllocation(groupId); + const allocations = overallAllocation?.allocations ?? {}; + const allocationsMap = new Map(Object.entries(allocations)); + const currentAllocation = allocationsMap.get(supplierId) ?? 0; + + const updatedAllocation = currentAllocation + newAllocation; + + if (overallAllocation) { + // Update existing allocation + const updatedAllocations = { + ...allocations, + [supplierId]: updatedAllocation, + }; + 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(date: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${date}`, + }, + }), + ); + if (!result.Item) { + return undefined; + } + // 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 { + const parsedAllocation = $DailyAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "daily-allocation", + allocation.date, + parsedAllocation, + ), + }), + ); + } + + async updateDailyAllocation( + date: string, + supplierId: string, + newAllocation: number, + ): Promise { + const dailyAllocation = await this.getDailyAllocation(date); + const allocations = dailyAllocation?.allocations ?? {}; + const allocationsMap = new Map(Object.entries(allocations)); + const currentAllocation = allocationsMap.get(supplierId) ?? 0; + const updatedAllocation = currentAllocation + newAllocation; + + if (dailyAllocation) { + // Update existing allocation + const updatedAllocations = { + ...allocations, + [supplierId]: updatedAllocation, + }; + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${date}`, + }, + UpdateExpression: + "SET allocations = :allocations, updatedAt = :updatedAt", + ExpressionAttributeValues: { + ":allocations": updatedAllocations, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } else { + // Create new allocation + const newDailyAllocation: DailyAllocation = { + id: `ID#${date}`, + date, + allocations: { [supplierId]: updatedAllocation }, + }; + await this.putDailyAllocation(newDailyAllocation); + } + } +} diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 730f91177..53708cedf 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,37 @@ 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.string(), + allocations: z.record( + idRef($Supplier, "id"), + z.number().int().nonnegative(), + ), + }) + .meta({ + title: "DailyAllocation", + description: + "The daily allocation for a given date, including all suppliers", + }); + +export type DailyAllocation = z.infer; 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/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/config/__tests__/deps.test.ts b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts index 7c2767f11..88d04eab5 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts @@ -3,13 +3,7 @@ import type { Deps } from "lambdas/supplier-allocator/src/config/deps"; describe("createDependenciesContainer", () => { const env = { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - billingId: "billing1", - }, - }, + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", }; beforeEach(() => { @@ -30,6 +24,7 @@ describe("createDependenciesContainer", () => { // Repo client jest.mock("@internal/datastore", () => ({ SupplierConfigRepository: jest.fn(), + SupplierQuotasRepository: jest.fn(), })); // Env @@ -42,6 +37,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 +49,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..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 */ @@ -16,33 +15,13 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable"; - process.env.VARIANT_MAP = `{ - "lv1": { - "supplierId": "supplier1", - "specId": "spec1", - "priority": 10, - "billingId": "billing1" - } - }`; + process.env.SUPPLIER_QUOTAS_TABLE_NAME = "SupplierQuotasTable"; const { envVars } = require("../env"); expect(envVars).toEqual({ SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - priority: 10, - billingId: "billing1", - }, - }, + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", }); }); - - 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/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..a155e4dbc 100644 --- a/lambdas/supplier-allocator/src/config/env.ts +++ b/lambdas/supplier-allocator/src/config/env.ts @@ -1,23 +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 eb1a3bfdb..7362cfc2d 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -1,15 +1,20 @@ +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 { $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 * as allocationConfig from "../allocation-config"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; @@ -21,6 +26,8 @@ const renderingSchemaVersion: string = ]; jest.mock("../../services/supplier-config"); +jest.mock("../../services/supplier-quotas"); +jest.mock("../allocation-config"); function createSQSEvent(records: SQSRecord[]): SQSEvent { return { @@ -144,19 +151,37 @@ function setupDefaultMocks() { (supplierConfig.getVariantDetails as jest.Mock).mockResolvedValue({ id: "v1", volumeGroupId: "g1", + priority: 1, }); (supplierConfig.getVolumeGroupDetails as jest.Mock).mockResolvedValue({ id: "g1", status: "PROD", }); + (allocationConfig.eligibleSuppliers as jest.Mock).mockResolvedValue({ + supplierAllocations: [{ supplier: "s1", variantId: "v1" }], + suppliers: [{ id: "s1", name: "Supplier 1", status: "PROD" }], + }); + (allocationConfig.preferredSupplierPack as jest.Mock).mockResolvedValue({ + 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( + "supplier1", + ); + (allocationConfig.suppliersWithValidPack as jest.Mock).mockResolvedValue([ + { id: "s1", name: "Supplier 1", status: "PROD" }, + ]); ( - supplierConfig.getSupplierAllocationsForVolumeGroup as jest.Mock - ).mockResolvedValue([{ supplier: "s1" }]); - (supplierConfig.getSupplierDetails as jest.Mock).mockResolvedValue({ + supplierQuotas.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue({ supplierId: "supplier-1", - specId: "spec-1", - priority: 1, - billingId: "billing-1", + factor: 0.5, }); } @@ -164,6 +189,7 @@ describe("createSupplierAllocatorHandler", () => { let mockSqsClient: jest.Mocked; let mockedDeps: jest.Mocked; let mockedSupplierConfigRepo: jest.Mocked; + let mockedSupplierQuotasRepo: jest.Mocked; beforeEach(() => { mockSqsClient = { send: jest.fn(), @@ -180,21 +206,26 @@ 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", - VARIANT_MAP: { - lv1: { - supplierId: "supplier1", - specId: "spec1", - priority: 1, - billingId: "billing1", - }, - }, + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", } as EnvVars, sqsClient: mockSqsClient, supplierConfigRepo: mockedSupplierConfigRepo, + supplierQuotasRepo: mockedSupplierQuotasRepo, } as jest.Mocked; jest.clearAllMocks(); }); @@ -295,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( @@ -394,35 +407,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("handles SQS send errors and returns batch failure", async () => { const preparedEvent = createPreparedV2Event(); @@ -502,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", @@ -512,4 +496,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(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", + 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..5d6f02079 --- /dev/null +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocation-config.test.ts @@ -0,0 +1,964 @@ +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, 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, mockDeps); + + expect(mockDeps.supplierQuotasRepo.getDailyAllocation).toHaveBeenCalledWith( + 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, 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, 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); + const result = await filterSuppliersWithCapacity(mockSuppliers, 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, 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([], 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, 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, mockDeps); + + const callArgs = ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mock.calls[0]; + const dateArg = callArgs[0]; + + 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, 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 2288fae12..3031da162 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -1,55 +1,33 @@ 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, - SupplierAllocation, 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"; import { - getSupplierAllocationsForVolumeGroup, - getSupplierDetails, getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; -import { Deps } from "../config/deps"; +import { updateSupplierAllocation } from "../services/supplier-quotas"; +import { + eligibleSuppliers, + filterSuppliersWithCapacity, + preferredSupplierPack, + selectSupplierByFactor, + suppliersWithValidPack, +} from "./allocation-config"; -type SupplierSpec = { - supplierId: string; - specId: string; - priority: number; - billingId: string; -}; -type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; +import { Deps } from "../config/deps"; +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 { - deps.logger.info({ - description: "Resolving supplier for letter variant", - variantId, - }); - 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) { @@ -64,37 +42,75 @@ 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( + 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 preferredPack: PackSpecification = await preferredSupplierPack( + letterEvent, + allocatedSuppliers, + letterVariant.packSpecificationIds, + deps, + ); - const supplierDetails: Supplier[] = await getSupplierDetails( - supplierAllocations, + const allSuppliersForPack: Supplier[] = await suppliersWithValidPack( + allocatedSuppliers, + preferredPack.id, deps, ); + + const suppliersForPackWithCapacity: Supplier[] = + await filterSuppliersWithCapacity(allSuppliersForPack, deps); + + // selected supplier id is determined by first calling selectSupplierByFactor for suppliers with capacity and if nothing is returned tryong again with all suppliers for pack + const selectedSupplierId = + (await selectSupplierByFactor( + suppliersForPackWithCapacity, + supplierAllocations, + deps, + )) ?? + (await selectSupplierByFactor( + allSuppliersForPack, + supplierAllocations, + deps, + )); + 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), - supplierDetails, + allocatedSuppliers, + allSuppliersForPack: allSuppliersForPack.map((s) => s.id), + suppliersForPackWithCapacity: suppliersForPackWithCapacity.map( + (s) => s.id, + ), + selectedSupplierId, }); + const supplierDetails: SupplierDetails = { + supplierSpec: { + supplierId: selectedSupplierId, + specId: preferredPack.id, + priority: letterVariant.priority, + billingId: preferredPack.billingId, + }, + volumeGroupId: volumeGroup.id, + }; return supplierDetails; } catch (error) { deps.logger.error({ @@ -102,15 +118,12 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { err: error, variantId: letterEvent.data.letterVariantId, }); - return []; + throw error; } } -function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec { - return resolveSupplierForVariant(letterEvent.data.letterVariantId, deps); -} - type AllocationMetrics = Map>; +type VolumeGroupAllocation = Map>; function incrementMetric( map: AllocationMetrics, @@ -144,11 +157,46 @@ function emitMetrics( } } +function incrementAllocation( + map: VolumeGroupAllocation, + 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, +) { + 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(); const tasks = event.Records.map(async (record) => { let supplier = "unknown"; @@ -163,8 +211,24 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { validateType(letterEvent); - const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps); - await getSupplierFromConfig(letterEvent as PreparedEvents, deps); + const supplierDetails: SupplierDetails = await getSupplierFromConfig( + letterEvent as PreparedEvents, + deps, + ); + const supplierSpec = supplierDetails?.supplierSpec; + + deps.logger.info({ + description: "Resolved supplier details from config", + supplierDetails, + }); + + incrementAllocation( + volumeGroupAllocations, + supplierDetails.volumeGroupId, + supplierDetails?.supplierSpec.supplierId, + 1, + deps, + ); supplier = supplierSpec.supplierId; priority = String(supplierSpec.priority); @@ -215,6 +279,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/handler/allocation-config.ts b/lambdas/supplier-allocator/src/handler/allocation-config.ts new file mode 100644 index 000000000..cdb63d5fb --- /dev/null +++ b/lambdas/supplier-allocator/src/handler/allocation-config.ts @@ -0,0 +1,119 @@ +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"; +import { PreparedEvents } from "./types"; + +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[], + 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(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/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/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index 7941d1f08..19ff804fc 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,10 @@ import { + filterPacksForLetter, + getPackSpecification, + getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, + getSupplierPacks, getVariantDetails, getVolumeGroupDetails, } from "../supplier-config"; @@ -31,7 +35,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 +192,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 +202,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 +212,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 +235,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ "s1", @@ -246,94 +243,399 @@ 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("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); + + 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("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); + + const result = await getSupplierDetails(supplierIds, deps); + + expect(result).toEqual([suppliers[0], suppliers[2]]); + expect(result.every((s) => s.status === "PROD")).toBe(true); + }); }); - 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 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(allocations, deps); - - expect(deps.logger.warn).toHaveBeenCalledWith({ - description: "Mismatch between supplier allocations and supplier details", - allocationsCount: 3, - detailsCount: 2, - missingSuppliers: ["s3"], + + 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", + }), + ); }); }); - it("does not log a warning when counts match", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; - 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); + 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); - await getSupplierDetails(allocations, deps); + expect(result).toBe(packSpec); + }); - expect(deps.logger.warn).not.toHaveBeenCalled(); + 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", + }), + ); + }); }); - it("throws when no active suppliers found", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; - 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(allocations, deps)).rejects.toThrow( - /No active suppliers found/, - ); - expect(deps.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - description: "No active suppliers found for supplier allocations", - }), - ); + 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"]); + }); }); - 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 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); + 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 getSupplierDetails(allocations, deps); + const result = await getSupplierPacks(packSpecificationId, deps); - expect(result).toEqual([suppliers[0], suppliers[2]]); - expect(result.every((s) => s.status === "PROD")).toBe(true); + 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..ef37971b8 --- /dev/null +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-quotas.test.ts @@ -0,0 +1,315 @@ +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: "ID#2024-01-15", + date: "2024-01-15", + 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("2024-01-15", "supplier1", 150); + }); + + it("should create new overall allocation when none exists", async () => { + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(null); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 100, mockDeps); + + expect( + mockDeps.supplierQuotasRepo.putOverallAllocation, + ).toHaveBeenCalledWith({ + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }); + }); + + it("should create new daily allocation when none exists", async () => { + const existingOverallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(existingOverallAllocation); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); + + expect( + mockDeps.supplierQuotasRepo.putDailyAllocation, + ).toHaveBeenCalledWith({ + id: "ID#2024-01-15", + date: "2024-01-15", + allocations: { + supplier1: 150, + }, + }); + }); + + it("should log when updating existing overall allocation", async () => { + const existingOverallAllocation: OverallAllocation = { + id: "vg1", + volumeGroup: "vg1", + allocations: { + supplier1: 100, + }, + }; + + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(existingOverallAllocation); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 150, mockDeps); + + expect(mockDeps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Existing overall allocation found for volume group", + volumeGroupId: "vg1", + }), + ); + }); + + it("should log when creating new overall allocation", async () => { + ( + mockDeps.supplierQuotasRepo.getOverallAllocation as jest.Mock + ).mockResolvedValue(null); + ( + mockDeps.supplierQuotasRepo.getDailyAllocation as jest.Mock + ).mockResolvedValue(null); + + await updateSupplierAllocation("vg1", "supplier1", 100, mockDeps); + + expect(mockDeps.logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "No overall allocation found for volume group, creating new one", + volumeGroupId: "vg1", + }), + ); + }); + }); +}); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 9710a68bd..9278a3422 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -1,10 +1,14 @@ import { LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; + import { Deps } from "../config/deps"; +import { PreparedEvents } from "../handler/types"; export async function getVariantDetails( variantId: string, @@ -75,11 +79,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 +95,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 +119,135 @@ 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; +} + +export async function getSupplierPacks( + packSpecificationId: string, + deps: Deps, +): Promise { + const supplierPacks = + await deps.supplierConfigRepo.getSupplierPacksForPackSpecification( + packSpecificationId, + ); + return supplierPacks; +} + +function evaluateContraint( + actualValue: number, + constraintValue: number, + operator: string, +): boolean { + 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 { + 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; +} 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..6ab9ba12c --- /dev/null +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -0,0 +1,93 @@ +import { DailyAllocation, 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 = + 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( + (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 }; + }); +} + +// 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(volumeGroupId); + if (overallAllocation) { + deps.logger.info({ + description: "Existing overall allocation found for volume group", + volumeGroupId, + overallAllocation, + }); + await deps.supplierQuotasRepo.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + } else { + const newOverallAllocation: OverallAllocation = { + id: volumeGroupId, + volumeGroup: volumeGroupId, + allocations: { + [supplierId]: newAllocation, + }, + }; + deps.logger.info({ + description: + "No overall allocation found for volume group, creating new one", + volumeGroupId, + newOverallAllocation, + }); + await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); + } + const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format + const dailyAllocation = + await deps.supplierQuotasRepo.getDailyAllocation(dailyAllocationDate); + if (dailyAllocation) { + await deps.supplierQuotasRepo.updateDailyAllocation( + dailyAllocationDate, + supplierId, + newAllocation, + ); + } else { + const newDailyAllocation: DailyAllocation = { + id: `ID#${dailyAllocationDate}`, + date: dailyAllocationDate, + allocations: { + [supplierId]: newAllocation, + }, + }; + await deps.supplierQuotasRepo.putDailyAllocation(newDailyAllocation); + } +} 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 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,