id: pharmacy-stock-expiry-alerts
namespace: company.team
description: |
Check a pharmacy stock file every week for medicine batches that are already
expired, expire soon, or have stock records that cannot be trusted (negative
quantities, unreadable expiry dates, the same batch listed twice), and report
the money at risk before the stock has to be written off.
inputs:
- id: stock_file
type: FILE
required: false
description: CSV with the columns branch, sku, product_name, batch_no, expiry,
quantity and unit_cost. The expiry can be a full date (2027-03-31) or the
month and year printed on the strip (03/2027, read as the last day of that
month). Leave empty to run against the built-in sample stock.
- id: critical_days
type: INT
defaults: 30
description: Batches expiring within this many days are critical. They usually
need to go back to the supplier or be sold first right now.
- id: watch_days
type: INT
defaults: 90
description: Batches expiring within this many days (but after critical_days)
are put on the watch list so they are picked first when dispensing.
- id: notify_slack
type: BOOL
defaults: false
description: Post the findings to Slack when at least one batch is critical or
has a data error. Requires the SLACK_WEBHOOK_URL secret.
triggers:
- id: weekly_expiry_check
type: io.kestra.plugin.core.trigger.Schedule
description: Check the stock every Monday at 08:00 IST, before the week's
purchase order goes to the distributor. Shipped disabled. Enable it once
the flow reads your real stock export instead of the sample.
cron: "0 8 * * MON"
timezone: Asia/Kolkata
disabled: true
tasks:
- id: sample_stock
type: io.kestra.plugin.core.storage.Write
description: A small two-branch stock file with one example of every problem the
check catches. Expiry dates are computed relative to today so the demo
always shows the same mix. Only used when no stock_file is provided.
extension: .csv
content: |
branch,sku,product_name,batch_no,expiry,quantity,unit_cost
Main Road,PCM500,Paracetamol 500mg strip of 15,PB2401,{{ now() | dateAdd(400, 'DAYS') | date('yyyy-MM-dd') }},120,18.50
Main Road,AMX250,Amoxicillin 250mg capsules strip of 10,AX1187,{{ now() | dateAdd(-12, 'DAYS') | date('yyyy-MM-dd') }},40,62.00
Main Road,ORS21,ORS sachet 21g,OR3302,{{ now() | dateAdd(18, 'DAYS') | date('yyyy-MM-dd') }},200,21.00
Main Road,INS100,Insulin glargine 100IU/ml pen,IG0912,{{ now() | dateAdd(55, 'DAYS') | date('yyyy-MM-dd') }},6,780.00
Main Road,MTF500,Metformin 500mg strip of 10,MF2207,{{ now() | dateAdd(-40, 'DAYS') | date('yyyy-MM-dd') }},0,25.00
Main Road,CGH100,Cough syrup 100ml,CS0098,31-13-2026,35,95.00
Lake View,CTZ10,Cetirizine 10mg strip of 10,CT7781,{{ now() | dateAdd(160, 'DAYS') | date('MM/yyyy') }},80,22.00
Lake View,AZT500,Azithromycin 500mg strip of 3,AZ5520,{{ now() | dateAdd(25, 'DAYS') | date('yyyy-MM-dd') }},30,112.00
Lake View,VTD60K,Vitamin D3 60000IU capsules strip of 4,VD1104,{{ now() | dateAdd(200, 'DAYS') | date('yyyy-MM-dd') }},-4,46.00
Lake View,PAN40,Pantoprazole 40mg strip of 15,PN4410,{{ now() | dateAdd(300, 'DAYS') | date('yyyy-MM-dd') }},50,105.00
Lake View,PAN40,Pantoprazole 40mg strip of 15,PN4410,{{ now() | dateAdd(120, 'DAYS') | date('yyyy-MM-dd') }},20,105.00
Lake View,ASP75,Aspirin 75mg strip of 14,AS3319,{{ now() | dateAdd(80, 'DAYS') | date('yyyy-MM-dd') }},60,8.00
- id: check_stock
type: io.kestra.plugin.jdbc.duckdb.Queries
description: Parse every batch, work out the days left until expiry and the
value of the stock, and classify each batch as CRITICAL, DATA_ERROR, WATCH
or OK in DuckDB. The full per-batch report is exported to CSV and the last
query returns the summary.
inputFiles:
stock.csv: "{{ inputs.stock_file ?? outputs.sample_stock.uri }}"
outputFiles:
- expiry_report.csv
fetchType: FETCH_ONE
sql: |
CREATE TABLE stock AS
SELECT
trim(branch) AS branch,
trim(sku) AS sku,
trim(product_name) AS product_name,
trim(batch_no) AS batch_no,
trim(coalesce(expiry, '')) AS expiry_raw,
quantity AS quantity_raw,
try_cast(quantity AS INTEGER) AS quantity,
try_cast(unit_cost AS DECIMAL(12, 2)) AS unit_cost
FROM read_csv('{{ workingDir }}/stock.csv', header = true, all_varchar = true);
CREATE TABLE parsed AS
SELECT
*,
coalesce(
try_strptime(expiry_raw, '%Y-%m-%d')::DATE,
last_day(try_strptime('01/' || expiry_raw, '%d/%m/%Y')::DATE)
) AS expiry_date,
count(*) OVER (PARTITION BY branch, sku, batch_no) AS times_listed
FROM stock;
CREATE TABLE report AS
SELECT
branch,
sku,
product_name,
batch_no,
expiry_raw,
expiry_date,
days_left,
quantity,
unit_cost,
stock_value,
status,
CASE status
WHEN 'DATA_ERROR' THEN array_to_string(data_errors, ', ')
WHEN 'CRITICAL' THEN CASE
WHEN days_left < 0 THEN 'Expired ' || -days_left || ' day(s) ago with ' || quantity || ' unit(s) still in stock: quarantine and remove from sale'
ELSE 'Expires in ' || days_left || ' day(s) with ' || quantity || ' unit(s) in stock: return to the supplier or sell first now'
END
WHEN 'WATCH' THEN 'Expires in ' || days_left || ' day(s): pick this batch first when dispensing'
WHEN 'OK' THEN CASE WHEN days_left < 0 THEN 'Expired but no stock left' ELSE '' END
END AS action
FROM (
SELECT
*,
expiry_date - current_date AS days_left,
round(coalesce(quantity, 0) * coalesce(unit_cost, 0), 2) AS stock_value,
list_filter([
CASE WHEN expiry_date IS NULL THEN 'Expiry "' || expiry_raw || '" is not a date (use YYYY-MM-DD or MM/YYYY)' END,
CASE WHEN quantity IS NULL THEN 'Quantity "' || coalesce(quantity_raw, '') || '" is not a whole number' END,
CASE WHEN quantity < 0 THEN 'Negative quantity ' || quantity || ' (stock count or billing error)' END,
CASE WHEN unit_cost IS NULL THEN 'Unit cost is missing or not a number' END,
CASE WHEN times_listed > 1 THEN 'Batch listed ' || times_listed || ' times in this branch (merge the rows or fix the batch number)' END
], lambda x: x IS NOT NULL) AS data_errors,
CASE
WHEN len(data_errors) > 0 THEN 'DATA_ERROR'
WHEN quantity > 0 AND expiry_date - current_date <= {{ inputs.critical_days }} THEN 'CRITICAL'
WHEN quantity > 0 AND expiry_date - current_date <= {{ inputs.watch_days }} THEN 'WATCH'
ELSE 'OK'
END AS status
FROM parsed
)
ORDER BY
CASE status WHEN 'CRITICAL' THEN 0 WHEN 'DATA_ERROR' THEN 1 WHEN 'WATCH' THEN 2 ELSE 3 END,
days_left NULLS FIRST,
branch,
sku;
COPY report TO '{{ outputFiles["expiry_report.csv"] }}' (HEADER, DELIMITER ',');
SELECT
count(*) AS total_batches,
count(*) FILTER (WHERE status = 'OK') AS ok_count,
count(*) FILTER (WHERE status = 'WATCH') AS watch_count,
count(*) FILTER (WHERE status = 'CRITICAL') AS critical_count,
count(*) FILTER (WHERE status = 'DATA_ERROR') AS data_error_count,
count(*) FILTER (WHERE status = 'CRITICAL' AND days_left < 0) AS expired_count,
coalesce(sum(stock_value) FILTER (WHERE status = 'CRITICAL'), 0)::DOUBLE AS value_at_risk,
coalesce(sum(stock_value) FILTER (WHERE status = 'WATCH'), 0)::DOUBLE AS value_on_watch,
coalesce(string_agg(branch || ' / ' || product_name || ' batch ' || batch_no || ': ' || action, chr(10)) FILTER (WHERE status = 'CRITICAL'), '') AS critical_details,
coalesce(string_agg(branch || ' / ' || product_name || ' batch ' || batch_no || ': ' || action, chr(10)) FILTER (WHERE status = 'DATA_ERROR'), '') AS data_error_details,
coalesce(string_agg(branch || ' / ' || product_name || ' batch ' || batch_no || ': ' || action, chr(10)) FILTER (WHERE status = 'WATCH'), '') AS watch_details
FROM report;
- id: route_findings
type: io.kestra.plugin.core.flow.If
description: Branch on the result. Critical batches and data errors need someone
to act this week, so they are logged as warnings and optionally sent to
Slack. A clean run is logged so the execution history shows when the stock
was last checked.
condition: "{{ outputs.check_stock.outputs[0].row.critical_count +
outputs.check_stock.outputs[0].row.data_error_count > 0 }}"
then:
- id: log_findings
type: io.kestra.plugin.core.log.Log
level: WARN
description: Write every critical batch, data error and watch-list batch to the
execution logs with the action to take.
message: |
Expiry check: {{ outputs.check_stock.outputs[0].row.critical_count }} critical ({{ outputs.check_stock.outputs[0].row.expired_count }} already expired), {{ outputs.check_stock.outputs[0].row.data_error_count }} data error(s), {{ outputs.check_stock.outputs[0].row.watch_count }} on the watch list, {{ outputs.check_stock.outputs[0].row.ok_count }} OK out of {{ outputs.check_stock.outputs[0].row.total_batches }} batches.
Stock value at risk: Rs {{ outputs.check_stock.outputs[0].row.value_at_risk | numberFormat('#,##0.00') }}
Critical:
{{ outputs.check_stock.outputs[0].row.critical_details }}
Data errors:
{{ outputs.check_stock.outputs[0].row.data_error_details }}
Watch list:
{{ outputs.check_stock.outputs[0].row.watch_details }}
- id: slack_enabled
type: io.kestra.plugin.core.flow.If
description: Only call Slack when the notify_slack input is true, so the flow
runs out of the box without any secret.
condition: "{{ inputs.notify_slack }}"
then:
- id: alert_pharmacy_team
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the pharmacy team which batches to pull, return or sell first,
and which stock records to fix.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
:pill: Expiry check: {{ outputs.check_stock.outputs[0].row.critical_count }} critical batch(es), {{ outputs.check_stock.outputs[0].row.data_error_count }} data error(s), Rs {{ outputs.check_stock.outputs[0].row.value_at_risk | numberFormat('#,##0.00') }} of stock at risk.
{{ outputs.check_stock.outputs[0].row.critical_details }}
{{ outputs.check_stock.outputs[0].row.data_error_details }}
Full report: execution {{ execution.id }} in flow {{ flow.namespace }}.{{ flow.id }}
else:
- id: log_clean
type: io.kestra.plugin.core.log.Log
description: Record a clean check, including how many batches are on the watch list.
message: "Expiry check passed: {{
outputs.check_stock.outputs[0].row.total_batches }} batches checked,
nothing expired or due within {{ inputs.critical_days }} days, {{
outputs.check_stock.outputs[0].row.watch_count }} batch(es) on the
watch list."
errors:
- id: log_check_failure
type: io.kestra.plugin.core.log.Log
level: ERROR
description: Make a failed check loud. If the file is missing or malformed, no
batch was checked, and a silent failure looks like a shelf with no expired
medicine.
message: "Expiry check FAILED in {{ flow.namespace }}.{{ flow.id }} (execution
{{ execution.id }}). No batches were checked. Make sure stock_file is a
CSV with the columns branch, sku, product_name, batch_no, expiry, quantity
and unit_cost."
outputs:
- id: expiry_report
type: FILE
description: Per-batch CSV report with the parsed expiry date, days left, stock
value, status (CRITICAL, DATA_ERROR, WATCH or OK) and the action to take.
value: "{{ outputs.check_stock.outputFiles['expiry_report.csv'] }}"
- id: expiry_summary
type: JSON
description: 'Counts, value at risk and details, e.g. {"total_batches": 12,
"critical_count": 3, "data_error_count": 4, "value_at_risk": 10040.0,
...}.'
value: "{{ outputs.check_stock.outputs[0].row | toJson }}"