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,