Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:csh%402018@localhost:5432/ai_pharmacy
|
||||||
|
PORT=8080
|
||||||
|
JWT_SECRET=super_secret_pharmacy_key_2026
|
||||||
|
|
||||||
|
# S3 CONFIGURATION
|
||||||
|
S3_KEY=AKIA3GRU7BB33WCCTN4V
|
||||||
|
S3_SECRET=EP/p8lLM11l9X+rcdomucLi2mN6Xr6b3ez0fh9BL
|
||||||
|
S3_REGION=ap-south-1
|
||||||
|
S3_BUCKET=a2zschoolfolio
|
||||||
|
S3_BASE_PATH=temp/
|
||||||
|
S3_PUBLIC_BASE_PATH=https://a2zschoolfolio.s3.ap-south-1.amazonaws.com/
|
||||||
|
|
||||||
|
|
||||||
|
GEMINI_API_KEY=AQ.Ab8RN6KkBPhZo9kmwV-mFBtriT3LF8vPWenXNfA2jSyVWWwMMQ
|
||||||
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
coverage/
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "AI Pharmacy Core API",
|
||||||
|
"description": "Complete collection for Phase 1 APIs.",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"value": "http://localhost:8080",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "access_token",
|
||||||
|
"value": "",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Health Check",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/health",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"health"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "1. Authentication",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Access Token",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"var jsonData = pm.response.json();",
|
||||||
|
"if (jsonData.access_token) {",
|
||||||
|
" pm.collectionVariables.set(\"access_token\", jsonData.access_token);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"grant_type\": \"client_credentials\",\n \"client_id\": \"jayam_web_ai\",\n \"client_secret\": \"my_secret_key\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/v1/auth/token",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"v1",
|
||||||
|
"auth",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2. Catalogue",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Search Drugs",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{access_token}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "X-Actor-Id",
|
||||||
|
"value": "usr_ph_1234"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/v1/drugs/search?q=Losartan",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"v1",
|
||||||
|
"drugs",
|
||||||
|
"search"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "q",
|
||||||
|
"value": "Losartan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3. Inventory",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Receive Goods (GRN)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{access_token}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "X-Actor-Id",
|
||||||
|
"value": "usr_ph_admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "idempotency-key",
|
||||||
|
"value": "idemp-{{$timestamp}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"lines\": [\n {\n \"batch_no\": \"BAT-{{$timestamp}}\",\n \"product_id\": \"prd_4410\",\n \"expiry\": \"2028-12-31\",\n \"qty\": 500,\n \"purchase_price\": \"50.00\"\n }\n ]\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/v1/inventory/grn",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"v1",
|
||||||
|
"inventory",
|
||||||
|
"grn"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Expiring Batches",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{access_token}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "X-Actor-Id",
|
||||||
|
"value": "usr_ph_1234"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/v1/inventory/expiring",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"v1",
|
||||||
|
"inventory",
|
||||||
|
"expiring"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4. Dispensing",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Dispense Order",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{access_token}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "X-Actor-Id",
|
||||||
|
"value": "usr_ph_1234"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "idempotency-key",
|
||||||
|
"value": "dispense-{{$timestamp}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"patient_id\": \"pat_101\",\n \"items\": [\n {\n \"product_id\": \"prd_4410\",\n \"qty\": 30\n }\n ]\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/v1/orders/dispense",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"v1",
|
||||||
|
"orders",
|
||||||
|
"dispense"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5. Audit",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Audit Logs",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "bearer",
|
||||||
|
"bearer": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "{{access_token}}",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "X-Actor-Id",
|
||||||
|
"value": "usr_ph_admin"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/v1/audit/logs?limit=10",
|
||||||
|
"host": [
|
||||||
|
"{{base_url}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"v1",
|
||||||
|
"audit",
|
||||||
|
"logs"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "limit",
|
||||||
|
"value": "10"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy'
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
await pool.query('ALTER TABLE dispensing_records ADD COLUMN IF NOT EXISTS prescription_id VARCHAR(50)');
|
||||||
|
console.log('Successfully added prescription_id column.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy image content
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
const ai = new GoogleGenAI();
|
||||||
|
async function run() {
|
||||||
|
const models = await ai.models.list();
|
||||||
|
for await (const m of models) {
|
||||||
|
console.log(m.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
Generated
+3951
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "ai_pharmacy_ai",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1094.0",
|
||||||
|
"@google/genai": "^2.13.0",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"redis": "^6.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"tsx": "^4.23.1",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
import { pool } from './pool';
|
||||||
|
|
||||||
|
async function initDB() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
console.log('Creating tables...');
|
||||||
|
|
||||||
|
// 1. Catalogue Module
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS drugs (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
generic_name VARCHAR(255) NOT NULL,
|
||||||
|
atc_code VARCHAR(50),
|
||||||
|
schedule VARCHAR(20),
|
||||||
|
is_nti BOOLEAN DEFAULT false,
|
||||||
|
pregnancy_category VARCHAR(10)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
drug_id VARCHAR(50) REFERENCES drugs(id),
|
||||||
|
brand_name VARCHAR(255) NOT NULL,
|
||||||
|
manufacturer VARCHAR(255),
|
||||||
|
strength VARCHAR(50),
|
||||||
|
form VARCHAR(50),
|
||||||
|
mrp DECIMAL(10, 2),
|
||||||
|
dpco_ceiling DECIMAL(10, 2)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. Inventory Module
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS batches (
|
||||||
|
batch_no VARCHAR(100) PRIMARY KEY,
|
||||||
|
product_id VARCHAR(50) REFERENCES products(id),
|
||||||
|
expiry DATE NOT NULL,
|
||||||
|
qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0),
|
||||||
|
purchase_price DECIMAL(10, 2),
|
||||||
|
status VARCHAR(50) DEFAULT 'available'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_movements (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
batch_no VARCHAR(100) REFERENCES batches(batch_no),
|
||||||
|
movement_type VARCHAR(50) NOT NULL, -- 'receipt', 'dispense', 'adjustment'
|
||||||
|
qty_delta INTEGER NOT NULL,
|
||||||
|
reference_id VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Dispensing & Audit
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dispensing_records (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
subject_ref VARCHAR(100) NOT NULL,
|
||||||
|
tenant_id VARCHAR(50) NOT NULL,
|
||||||
|
dispensed_by VARCHAR(100) NOT NULL,
|
||||||
|
prescription_id VARCHAR(50),
|
||||||
|
total_amount DECIMAL(10, 2) DEFAULT 0,
|
||||||
|
payment_method VARCHAR(50) DEFAULT 'cash',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS overrides_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
dispensing_id VARCHAR(50) REFERENCES dispensing_records(id),
|
||||||
|
code VARCHAR(50) NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
actor_user_id VARCHAR(100) NOT NULL,
|
||||||
|
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS h1_registers (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
dispensing_id VARCHAR(50) REFERENCES dispensing_records(id),
|
||||||
|
patient_name VARCHAR(255) NOT NULL,
|
||||||
|
prescriber_name VARCHAR(255),
|
||||||
|
drug_name VARCHAR(255) NOT NULL,
|
||||||
|
batch_no VARCHAR(100),
|
||||||
|
qty INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS order_drafts (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
subject_ref VARCHAR(100),
|
||||||
|
prescription_ref VARCHAR(50),
|
||||||
|
items JSONB,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
call_id VARCHAR(100),
|
||||||
|
actor_user_id VARCHAR(100),
|
||||||
|
task_type VARCHAR(50),
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. Phase 2: Prescriptions
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS prescriptions (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
image_hash VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'processing',
|
||||||
|
extracted_data JSONB,
|
||||||
|
confirmed_data JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Seeding initial data...');
|
||||||
|
// Seed Dummy Data for UI
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO drugs (id, generic_name, schedule) VALUES
|
||||||
|
('drg_001', 'Losartan Potassium', 'H'),
|
||||||
|
('drg_002', 'Sertraline', 'H')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO products (id, drug_id, brand_name, form, mrp) VALUES
|
||||||
|
('prd_4410', 'drg_001', 'Losar 50', 'tablet', 78.50),
|
||||||
|
('prd_5510', 'drg_002', 'Sertra 50', 'tablet', 110.00)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add batches. Let's add a near-expiry batch so the UI alert triggers
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) VALUES
|
||||||
|
('B4421', 'prd_4410', CURRENT_DATE + INTERVAL '15 days', 240, 50.00),
|
||||||
|
('B4630', 'prd_5510', CURRENT_DATE + INTERVAL '200 days', 500, 80.00)
|
||||||
|
ON CONFLICT (batch_no) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log('Database initialization and seeding completed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error initializing database:', error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initDB();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/ai_pharmacy',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
pool.on('connect', () => {
|
||||||
|
console.log('Connected to the PostgreSQL database.');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('Unexpected error on idle client', err);
|
||||||
|
process.exit(-1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { errorHandler } from './middleware/error';
|
||||||
|
|
||||||
|
// Import routers
|
||||||
|
import identityRouter from './modules/identity/router';
|
||||||
|
import catalogueRouter from './modules/catalogue/router';
|
||||||
|
import inventoryRouter from './modules/inventory/router';
|
||||||
|
import dispensingRouter from './modules/dispensing/router';
|
||||||
|
import auditRouter from './modules/audit/router';
|
||||||
|
import prescriptionRouter from './modules/prescription/router';
|
||||||
|
import aiRouter from './modules/ai/router';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Detailed Network Logger Middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
console.log(`\n======================================================`);
|
||||||
|
console.log(`[NETWORK REQUEST] ${req.method} ${req.originalUrl}`);
|
||||||
|
console.log(`[HEADERS]`, JSON.stringify(req.headers, null, 2));
|
||||||
|
if (req.body && Object.keys(req.body).length > 0) {
|
||||||
|
console.log(`[BODY]`, JSON.stringify(req.body, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
console.log(`[NETWORK RESPONSE] ${req.method} ${req.originalUrl} - Status: ${res.statusCode} (${duration}ms)`);
|
||||||
|
console.log(`======================================================\n`);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/v1/auth', identityRouter);
|
||||||
|
app.use('/v1', catalogueRouter);
|
||||||
|
app.use('/v1/inventory', inventoryRouter);
|
||||||
|
app.use('/v1/orders', dispensingRouter);
|
||||||
|
app.use('/v1/audit', auditRouter);
|
||||||
|
app.use('/v1/rx', prescriptionRouter);
|
||||||
|
app.use('/v1/ai', aiRouter);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global Error Handler
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Pharmacy Core API listening on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
actorId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireAuth: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const actorId = req.headers['x-actor-id'] as string;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({
|
||||||
|
error: 'UNAUTHORIZED',
|
||||||
|
message: 'Missing or invalid Authorization header.',
|
||||||
|
details: {},
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actorId) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: 'FORBIDDEN',
|
||||||
|
message: 'Missing X-Actor-Id header.',
|
||||||
|
details: {},
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cryptographically verify the token using our secret key
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
|
||||||
|
// Automatically attach the tenant (e.g., jayam_web_ai) to the request context
|
||||||
|
req.tenantId = payload.tenantId;
|
||||||
|
req.actorId = actorId;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({
|
||||||
|
error: 'UNAUTHORIZED',
|
||||||
|
message: 'Invalid or expired token.',
|
||||||
|
details: {},
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
const status = err.status || 500;
|
||||||
|
const errorName = err.name || 'INTERNAL_SERVER_ERROR';
|
||||||
|
const message = err.message || 'An unexpected error occurred.';
|
||||||
|
const correlationId = req.headers['x-correlation-id'] || 'unknown';
|
||||||
|
|
||||||
|
res.status(status).json({
|
||||||
|
error: errorName,
|
||||||
|
message: message,
|
||||||
|
details: err.details || {},
|
||||||
|
correlation_id: correlationId,
|
||||||
|
retryable: err.retryable || false
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export const requireHeaders = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const actorId = req.headers['x-actor-id'];
|
||||||
|
|
||||||
|
if (!actorId) {
|
||||||
|
return next({
|
||||||
|
status: 400,
|
||||||
|
name: 'MISSING_HEADER',
|
||||||
|
message: 'X-Actor-Id header is required on all mutating endpoints.',
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { pool } from '../db/pool';
|
||||||
|
|
||||||
|
export const requireIdempotency = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const key = req.headers['idempotency-key'];
|
||||||
|
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return next({
|
||||||
|
status: 400,
|
||||||
|
name: 'MISSING_IDEMPOTENCY_KEY',
|
||||||
|
message: 'Idempotency-Key header is mandatory for this endpoint.',
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up old keys (older than 24 hours) - in production this would be a cron job
|
||||||
|
await pool.query(`DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'`);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT * FROM idempotency_keys WHERE key = $1`, [key]);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
// Key exists, return previous response
|
||||||
|
const record = rows[0];
|
||||||
|
return res.status(record.response_status).json(record.response_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key doesn't exist, wrap res.json to capture response
|
||||||
|
const originalJson = res.json;
|
||||||
|
res.json = function(body: any) {
|
||||||
|
// Store the response asynchronously
|
||||||
|
pool.query(`
|
||||||
|
INSERT INTO idempotency_keys (key, method, path, response_body, response_status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
`, [key, req.method, req.originalUrl, body, res.statusCode]).catch(err => {
|
||||||
|
console.error('Failed to save idempotency key:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return originalJson.call(this, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { AIService } from './service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const aiService = new AIService();
|
||||||
|
|
||||||
|
router.post('/voice', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { transcript } = req.body;
|
||||||
|
if (!transcript) {
|
||||||
|
return res.status(400).json({ error: 'Transcript is required' });
|
||||||
|
}
|
||||||
|
const result = await aiService.processVoiceCommand(transcript);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import { pool } from '../../db/pool';
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||||
|
|
||||||
|
export class AIService {
|
||||||
|
async processVoiceCommand(transcript: string) {
|
||||||
|
const prompt = `You are a helpful pharmacy AI assistant.
|
||||||
|
You can understand English and Tanglish (Tamil written in English).
|
||||||
|
The user is speaking to you. Respond conversationally. Do not use markdown.
|
||||||
|
If the user asks about low stock, use get_low_stock.
|
||||||
|
If the user asks about total medicines, use get_total_medicines.
|
||||||
|
If the user asks about today's sales or revenue, use get_today_sales.
|
||||||
|
If the user asks about expiring medicines, use get_expiring_medicines.
|
||||||
|
If the user asks to open or navigate to a page (e.g. "open prescriptions", "take me to pharmacy"), use navigate_to with the appropriate target.
|
||||||
|
If the user asks to search for a specific medicine or asks if a medicine is available (e.g., "search privika", "do we have calpol"), use search_medicine.
|
||||||
|
If the user asks to add a new medicine to the system, use add_medicine.
|
||||||
|
If the user asks to add stock for a medicine, use add_stock.
|
||||||
|
User transcript: "${transcript}"`;
|
||||||
|
|
||||||
|
const aiTools = {
|
||||||
|
functionDeclarations: [
|
||||||
|
{
|
||||||
|
name: "get_low_stock",
|
||||||
|
description: "Get the count of medicines that are running low on stock.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_total_medicines",
|
||||||
|
description: "Get the total number of unique medicines in the system.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_today_sales",
|
||||||
|
description: "Get the total revenue or sales for today.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_expiring_medicines",
|
||||||
|
description: "Get the count of medicines expiring within the next 30 days.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search_medicine",
|
||||||
|
description: "Search for a specific medicine by name to check if it's in stock.",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "STRING",
|
||||||
|
description: "The name of the medicine to search for (e.g., 'calpol', 'privika')."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["query"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_medicine",
|
||||||
|
description: "Add a new medicine to the product catalog.",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
brand_name: { type: "STRING", description: "The brand name of the medicine (e.g., Dolo 650)." },
|
||||||
|
mrp: { type: "NUMBER", description: "The Maximum Retail Price (MRP) in rupees." },
|
||||||
|
form: { type: "STRING", description: "The physical form of the medicine (e.g., tablet, syrup, injection)." }
|
||||||
|
},
|
||||||
|
required: ["brand_name", "mrp"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_stock",
|
||||||
|
description: "Add stock (a new batch) for an existing medicine.",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
product_name: { type: "STRING", description: "The brand name of the medicine to add stock to." },
|
||||||
|
qty: { type: "NUMBER", description: "The quantity of stock to add." },
|
||||||
|
batch_no: { type: "STRING", description: "The batch number. Omit if the user doesn't provide one." },
|
||||||
|
expiry: { type: "STRING", description: "The expiry date. Omit if the user doesn't provide one." }
|
||||||
|
},
|
||||||
|
required: ["product_name", "qty"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "navigate_to",
|
||||||
|
description: "Navigate the user to a specific page.",
|
||||||
|
parameters: {
|
||||||
|
type: "OBJECT",
|
||||||
|
properties: {
|
||||||
|
target: {
|
||||||
|
type: "STRING",
|
||||||
|
description: "The page to navigate to. Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["target"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: 'gemini-3.6-flash',
|
||||||
|
contents: [prompt],
|
||||||
|
config: {
|
||||||
|
tools: [aiTools as any],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const functionCalls = response.functionCalls;
|
||||||
|
|
||||||
|
if (functionCalls && functionCalls.length > 0) {
|
||||||
|
const call = functionCalls[0];
|
||||||
|
let toolData = null;
|
||||||
|
let actionCommand = 'NONE';
|
||||||
|
|
||||||
|
if (call.name === 'get_low_stock') {
|
||||||
|
const { rows } = await pool.query('SELECT count(*) as count FROM batches WHERE qty_on_hand <= 50');
|
||||||
|
toolData = { items_running_low: rows[0].count };
|
||||||
|
actionCommand = 'VIEW_LOW_STOCK';
|
||||||
|
} else if (call.name === 'get_total_medicines') {
|
||||||
|
const { rows } = await pool.query('SELECT count(*) as count FROM products');
|
||||||
|
toolData = { total_unique_medicines: rows[0].count };
|
||||||
|
} else if (call.name === 'get_today_sales') {
|
||||||
|
const { rows } = await pool.query(`SELECT sum(total_amount) as total FROM dispensing_records WHERE DATE(created_at) = CURRENT_DATE`);
|
||||||
|
toolData = { todays_revenue_rupees: rows[0].total || 0 };
|
||||||
|
} else if (call.name === 'get_expiring_medicines') {
|
||||||
|
const { rows } = await pool.query(`SELECT count(*) as count FROM batches WHERE expiry <= CURRENT_DATE + interval '30 days'`);
|
||||||
|
toolData = { batches_expiring_in_30_days: rows[0].count };
|
||||||
|
actionCommand = 'VIEW_EXPIRING_STOCK';
|
||||||
|
} else if (call.name === 'search_medicine') {
|
||||||
|
const query = (call.args as any)?.query || '';
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT p.brand_name, COALESCE(SUM(b.qty_on_hand), 0) as total_stock
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN batches b ON p.id = b.product_id
|
||||||
|
WHERE p.brand_name ILIKE $1
|
||||||
|
GROUP BY p.brand_name`,
|
||||||
|
[`%${query}%`]
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
toolData = { search_results: rows };
|
||||||
|
} else {
|
||||||
|
toolData = { message: `No medicine found matching '${query}'.` };
|
||||||
|
}
|
||||||
|
actionCommand = 'NAVIGATE_INVENTORY';
|
||||||
|
} else if (call.name === 'add_medicine') {
|
||||||
|
const args = call.args as any;
|
||||||
|
const id = `prd_${Date.now()}`;
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO products (id, brand_name, form, mrp) VALUES ($1, $2, $3, $4)',
|
||||||
|
[id, args.brand_name, args.form || 'tablet', args.mrp || 0]
|
||||||
|
);
|
||||||
|
toolData = { success: true, message: `Added ${args.brand_name} successfully.` };
|
||||||
|
actionCommand = 'NAVIGATE_CATALOGUE'; // Assume there's a catalogue or inventory view
|
||||||
|
} else if (call.name === 'add_stock') {
|
||||||
|
const args = call.args as any;
|
||||||
|
const pRes = await pool.query('SELECT id, brand_name FROM products WHERE brand_name ILIKE $1 LIMIT 1', [`%${args.product_name}%`]);
|
||||||
|
|
||||||
|
if (pRes.rows.length > 0) {
|
||||||
|
const pid = pRes.rows[0].id;
|
||||||
|
const batchNo = args.batch_no || `B${Date.now()}`;
|
||||||
|
// Default expiry to 1 year from now if not provided
|
||||||
|
const expiry = args.expiry || new Date(Date.now() + 31536000000).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) VALUES ($1, $2, $3, $4, $5)',
|
||||||
|
[batchNo, pid, expiry, args.qty, 0]
|
||||||
|
);
|
||||||
|
toolData = { success: true, message: `Added ${args.qty} stock to ${pRes.rows[0].brand_name}.` };
|
||||||
|
actionCommand = 'NAVIGATE_INVENTORY';
|
||||||
|
} else {
|
||||||
|
toolData = { success: false, message: `Could not find product ${args.product_name} in database. Please add the medicine first.` };
|
||||||
|
}
|
||||||
|
} else if (call.name === 'navigate_to') {
|
||||||
|
const target = (call.args as any)?.target;
|
||||||
|
toolData = { action: 'Navigating to ' + target };
|
||||||
|
if (target === 'dashboard') actionCommand = 'NAVIGATE_DASHBOARD';
|
||||||
|
if (target === 'pharmacy') actionCommand = 'NAVIGATE_PHARMACY';
|
||||||
|
if (target === 'inventory') actionCommand = 'NAVIGATE_INVENTORY';
|
||||||
|
if (target === 'prescriptions') actionCommand = 'NAVIGATE_PRESCRIPTIONS';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Ask Gemini to generate the final response in the user's language
|
||||||
|
const finalPrompt = `You are a helpful pharmacy AI assistant.
|
||||||
|
User's query: "${transcript}"
|
||||||
|
Database result: ${JSON.stringify(toolData)}
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
1. Detect the language of the User's query (it may be English, Tamil script, or Tanglish - Tamil in English letters).
|
||||||
|
2. You MUST reply in the EXACT SAME LANGUAGE as the user.
|
||||||
|
3. If the user spoke Tanglish (e.g., "evlo irukku"), you MUST reply in Tanglish (e.g., "Motham 10 irukku").
|
||||||
|
4. NEVER reply in English unless the user spoke in English. Keep it conversational and concise.`;
|
||||||
|
|
||||||
|
const finalResponse = await ai.models.generateContent({
|
||||||
|
model: 'gemini-3.6-flash',
|
||||||
|
contents: [finalPrompt]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
replyText: finalResponse.text || "Action completed.",
|
||||||
|
actionCommand
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replyText: response.text || "I didn't quite catch that.",
|
||||||
|
actionCommand: 'NONE'
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AI Error:", e);
|
||||||
|
return {
|
||||||
|
replyText: "Sorry, I am having trouble connecting to the AI system.",
|
||||||
|
actionCommand: 'NONE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth';
|
||||||
|
import { pool } from '../../db/pool';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /v1/audit/ai-calls
|
||||||
|
router.get('/ai-calls', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { from, to, limit } = req.query;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT id, call_id, actor_user_id, task_type, details, created_at
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
values.push(from);
|
||||||
|
query += ` AND created_at >= $${values.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
values.push(to);
|
||||||
|
query += ` AND created_at <= $${values.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(limit ? parseInt(limit as string) : 50);
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT $${values.length}`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, values);
|
||||||
|
|
||||||
|
// Also grab the stock movements for full audit visibility
|
||||||
|
const { rows: stockMovements } = await pool.query(`
|
||||||
|
SELECT id, batch_no, movement_type, qty_delta, reference_id, created_at
|
||||||
|
FROM stock_movements
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
`, [limit ? parseInt(limit as string) : 50]);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
ai_calls: rows,
|
||||||
|
stock_movements: stockMovements
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/audit/overrides
|
||||||
|
router.get('/overrides', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
dispensing_id,
|
||||||
|
code,
|
||||||
|
reason,
|
||||||
|
actor_user_id,
|
||||||
|
occurred_at
|
||||||
|
FROM overrides_log
|
||||||
|
ORDER BY occurred_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Grouping for summary
|
||||||
|
const by_actor: Record<string, number> = {};
|
||||||
|
const by_code: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
by_actor[row.actor_user_id] = (by_actor[row.actor_user_id] || 0) + 1;
|
||||||
|
by_code[row.code] = (by_code[row.code] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
overrides: rows,
|
||||||
|
summary: {
|
||||||
|
by_actor,
|
||||||
|
by_code
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/audit/h1-register
|
||||||
|
router.get('/h1-register', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id, dispensing_id, patient_name, prescriber_name, drug_name, batch_no, qty, created_at
|
||||||
|
FROM h1_registers
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
res.status(200).json({ register: rows });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/audit/sales-register
|
||||||
|
router.get('/sales-register', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
d.id as invoice_no,
|
||||||
|
d.created_at as invoice_date,
|
||||||
|
d.subject_ref as patient,
|
||||||
|
d.dispensed_by,
|
||||||
|
d.total_amount,
|
||||||
|
d.payment_method
|
||||||
|
FROM dispensing_records d
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
`);
|
||||||
|
res.status(200).json({ register: rows });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth';
|
||||||
|
import { CatalogueService } from './service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const service = new CatalogueService();
|
||||||
|
|
||||||
|
// GET /v1/drugs
|
||||||
|
router.get('/drugs', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
|
const drugs = await service.getAllDrugs(limit);
|
||||||
|
res.status(200).json({ drugs });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/drugs
|
||||||
|
router.post('/drugs', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await service.addMedicine(req.body);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/drugs/search
|
||||||
|
router.get('/drugs/search', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const q = (req.query.q as string) || '';
|
||||||
|
const form = req.query.form ? (req.query.form as string) : undefined;
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
|
||||||
|
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Search query "q" is required and must be at least 2 characters.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await service.searchDrugs(q, form, limit);
|
||||||
|
res.status(200).json({ candidates });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/products/:product_id/batches
|
||||||
|
router.get('/products/:product_id/batches', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const productId = req.params.product_id as string;
|
||||||
|
const batches = await service.getProductBatches(productId);
|
||||||
|
res.status(200).json({ batches });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { pool } from '../../db/pool';
|
||||||
|
|
||||||
|
export class CatalogueService {
|
||||||
|
async searchDrugs(q: string, form?: string, limit: number = 10) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.brand_name,
|
||||||
|
d.generic_name,
|
||||||
|
p.strength,
|
||||||
|
p.form,
|
||||||
|
p.manufacturer,
|
||||||
|
d.schedule,
|
||||||
|
d.is_nti,
|
||||||
|
d.pregnancy_category,
|
||||||
|
p.mrp,
|
||||||
|
p.dpco_ceiling,
|
||||||
|
(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE) as total_stock
|
||||||
|
FROM products p
|
||||||
|
JOIN drugs d ON p.drug_id = d.id
|
||||||
|
WHERE p.brand_name ILIKE $1 OR d.generic_name ILIKE $1
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
const values = [`%${q}%`, limit];
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllDrugs(limit: number = 50) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.id as product_id,
|
||||||
|
p.brand_name,
|
||||||
|
d.generic_name,
|
||||||
|
p.strength,
|
||||||
|
p.form,
|
||||||
|
p.manufacturer,
|
||||||
|
d.schedule,
|
||||||
|
d.is_nti,
|
||||||
|
d.pregnancy_category,
|
||||||
|
p.mrp,
|
||||||
|
p.dpco_ceiling,
|
||||||
|
(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE) as total_stock
|
||||||
|
FROM products p
|
||||||
|
JOIN drugs d ON p.drug_id = d.id
|
||||||
|
ORDER BY p.brand_name ASC
|
||||||
|
LIMIT $1
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(query, [limit]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductBatches(productId: string) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
batch_no,
|
||||||
|
expiry,
|
||||||
|
qty_on_hand,
|
||||||
|
EXTRACT(DAY FROM (expiry - CURRENT_DATE)) as days_to_expiry,
|
||||||
|
CASE
|
||||||
|
WHEN expiry <= CURRENT_DATE THEN 'expired'
|
||||||
|
WHEN expiry <= CURRENT_DATE + interval '30 days' THEN 'near_expiry'
|
||||||
|
ELSE 'available'
|
||||||
|
END as status
|
||||||
|
FROM batches
|
||||||
|
WHERE product_id = $1 AND qty_on_hand > 0
|
||||||
|
ORDER BY expiry ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [productId]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMedicine(payload: any) {
|
||||||
|
const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp } = payload;
|
||||||
|
|
||||||
|
// Check if generic exists
|
||||||
|
let drugId;
|
||||||
|
const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1`, [generic_name]);
|
||||||
|
if (drugs.length > 0) {
|
||||||
|
drugId = drugs[0].id;
|
||||||
|
} else {
|
||||||
|
drugId = 'drg_' + Date.now();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO drugs (id, generic_name, schedule) VALUES ($1, $2, $3)`,
|
||||||
|
[drugId, generic_name, schedule || 'H']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = 'prd_' + Date.now();
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO products (id, drug_id, brand_name, manufacturer, strength, form, mrp)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, [productId, drugId, brand_name, manufacturer, strength, form, mrp]);
|
||||||
|
|
||||||
|
return { product_id: productId, drug_id: drugId };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth';
|
||||||
|
import { requireHeaders } from '../../middleware/headers';
|
||||||
|
import { requireIdempotency } from '../../middleware/idempotency';
|
||||||
|
import { DispensingService } from './service';
|
||||||
|
import { pool } from '../../db/pool';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const service = new DispensingService();
|
||||||
|
|
||||||
|
// GET /v1/orders/history
|
||||||
|
router.get('/history', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const history = await service.getDispensingHistory();
|
||||||
|
res.status(200).json({ history });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/orders/h1-register
|
||||||
|
router.get('/h1-register', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const records = await service.getH1Register();
|
||||||
|
res.status(200).json({ records });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/orders
|
||||||
|
router.post('/', requireAuth, requireHeaders, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const draft = await service.createOrderDraft(req.body);
|
||||||
|
res.status(201).json(draft);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /v1/orders/:id/items
|
||||||
|
router.patch('/:id/items', requireAuth, requireHeaders, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const draft = await service.updateOrderItems(req.params.id as string, req.body.items);
|
||||||
|
res.status(200).json(draft);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/orders/:id/precheck
|
||||||
|
router.post('/:id/precheck', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { clinical_context } = req.body;
|
||||||
|
// We would fetch the order items from the DB here, but for now we accept them from body or mock
|
||||||
|
const items = req.body.items || [];
|
||||||
|
|
||||||
|
if (!clinical_context) {
|
||||||
|
return res.status(400).json({ error: 'CONTEXT_INCOMPLETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const precheckResult = await service.runGates(clinical_context, items);
|
||||||
|
res.status(200).json(precheckResult);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/orders/:id/dispense
|
||||||
|
router.post('/:id/dispense', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const idempotencyKey = req.headers['idempotency-key'];
|
||||||
|
if (!idempotencyKey) {
|
||||||
|
return res.status(400).json({ error: 'MISSING_IDEMPOTENCY_KEY' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorId = req.actorId;
|
||||||
|
if (!actorId) {
|
||||||
|
return res.status(403).json({ error: 'MISSING_ACTOR_ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prescriptionId = req.params.id;
|
||||||
|
let { subject_ref, prescriber_name, items, payment_method, overrides } = req.body;
|
||||||
|
|
||||||
|
// If items are not provided, fetch them from the draft order
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
const draftRes = await pool.query('SELECT items FROM order_drafts WHERE id = $1', [prescriptionId]);
|
||||||
|
if (draftRes.rows.length > 0 && draftRes.rows[0].items) {
|
||||||
|
items = typeof draftRes.rows[0].items === 'string' ? JSON.parse(draftRes.rows[0].items) : draftRes.rows[0].items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'NO_ITEMS_PROVIDED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass to service which runs the SERIALIZABLE transaction
|
||||||
|
const result = await service.executeDispense(subject_ref, prescriber_name || 'Unknown Prescriber', items, actorId as string, req.tenantId as string | undefined, prescriptionId as string | undefined, payment_method as string, overrides || []);
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 409) {
|
||||||
|
return res.status(409).json(error);
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { pool } from '../../db/pool';
|
||||||
|
import { webhookService } from '../webhooks/service';
|
||||||
|
|
||||||
|
export class DispensingService {
|
||||||
|
async createOrderDraft(payload: any) {
|
||||||
|
const draftId = "ord_" + Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
const items = payload.items ? JSON.stringify(payload.items) : '[]';
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO order_drafts (id, subject_ref, prescription_ref, items)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, [draftId, payload.subject_ref, payload.prescription_ref || null, items]);
|
||||||
|
|
||||||
|
return { order_draft_id: draftId, status: 'draft' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrderItems(draftId: string, items: any[]) {
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE order_drafts
|
||||||
|
SET items = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2
|
||||||
|
`, [JSON.stringify(items), draftId]);
|
||||||
|
|
||||||
|
return { order_draft_id: draftId, updated_items: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async runGates(clinicalContext: any, items: { product_id: string, qty: number }[]) {
|
||||||
|
// Phase 1: Basic hard/soft gate mock implementations based on clinical context
|
||||||
|
const blocks: any[] = [];
|
||||||
|
const warnings: any[] = [];
|
||||||
|
let tier = 'routine';
|
||||||
|
let totalAmount = 0;
|
||||||
|
|
||||||
|
if (clinicalContext?.pregnancy?.status === 'pregnant') {
|
||||||
|
blocks.push({
|
||||||
|
code: 'PREGNANCY_CATEGORY_D_X',
|
||||||
|
severity: 'hard',
|
||||||
|
detail: 'Fetotoxic in pregnancy.',
|
||||||
|
overridable: false
|
||||||
|
});
|
||||||
|
tier = 'elevated';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clinicalContext?.allergies?.length > 0) {
|
||||||
|
tier = 'elevated';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry soft gates from DB and calculate total amount
|
||||||
|
for (const item of items) {
|
||||||
|
const prodRes = await pool.query(`SELECT mrp, dpco_ceiling FROM products WHERE id = $1`, [item.product_id]);
|
||||||
|
if (prodRes.rows.length > 0) {
|
||||||
|
const prod = prodRes.rows[0];
|
||||||
|
const mrp = parseFloat(prod.mrp) || 0;
|
||||||
|
const dpco = parseFloat(prod.dpco_ceiling);
|
||||||
|
if (!isNaN(dpco) && mrp > dpco) {
|
||||||
|
blocks.push({
|
||||||
|
code: 'DPCO_CEILING_EXCEEDED',
|
||||||
|
severity: 'hard',
|
||||||
|
detail: `Billed price (MRP ${mrp}) exceeds DPCO ceiling (${dpco}).`,
|
||||||
|
overridable: false
|
||||||
|
});
|
||||||
|
tier = 'elevated';
|
||||||
|
}
|
||||||
|
totalAmount += mrp * item.qty;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT batch_no, expiry
|
||||||
|
FROM batches
|
||||||
|
WHERE product_id = $1 AND qty_on_hand > 0
|
||||||
|
ORDER BY expiry ASC LIMIT 1
|
||||||
|
`, [item.product_id]);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const batch = rows[0];
|
||||||
|
const daysToExpiry = (new Date(batch.expiry).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (daysToExpiry <= 30 && daysToExpiry > 0) {
|
||||||
|
warnings.push({
|
||||||
|
code: 'NEAR_EXPIRY',
|
||||||
|
batch_no: batch.batch_no,
|
||||||
|
expiry: batch.expiry,
|
||||||
|
overridable: true
|
||||||
|
});
|
||||||
|
tier = tier === 'routine' ? 'standard' : tier;
|
||||||
|
} else if (daysToExpiry <= 0) {
|
||||||
|
blocks.push({
|
||||||
|
code: 'EXPIRED_BATCH',
|
||||||
|
severity: 'hard',
|
||||||
|
detail: 'Batch past expiry',
|
||||||
|
overridable: false
|
||||||
|
});
|
||||||
|
tier = 'elevated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks,
|
||||||
|
interactions: [],
|
||||||
|
warnings,
|
||||||
|
confirmation_tier: tier,
|
||||||
|
total_amount: totalAmount,
|
||||||
|
context_as_of: clinicalContext?.as_of || new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeDispense(subjectRef: string, prescriberName: string, items: any[], actorId: string, tenantId?: string, prescriptionId?: string, paymentMethod: string = 'cash', overrides: any[] = []) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const h1Entries: string[] = [];
|
||||||
|
let dispensingId = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API spec requires SERIALIZABLE transaction for absolute safety against concurrent dispensings
|
||||||
|
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||||
|
|
||||||
|
dispensingId = "dsp_" + Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
let totalAmount = 0;
|
||||||
|
const pendingQueries: { query: string, params: any[] }[] = [];
|
||||||
|
const allocations = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
let remainingQtyToAllocate = item.qty;
|
||||||
|
|
||||||
|
// Fetch product and drug details first
|
||||||
|
const prodRes = await client.query(`
|
||||||
|
SELECT p.brand_name, p.mrp, p.dpco_ceiling, d.schedule, d.generic_name
|
||||||
|
FROM products p
|
||||||
|
JOIN drugs d ON p.drug_id = d.id
|
||||||
|
WHERE p.id = $1
|
||||||
|
`, [item.product_id]);
|
||||||
|
|
||||||
|
if (prodRes.rows.length === 0) {
|
||||||
|
throw { status: 400, error: 'INVALID_PRODUCT', message: `Product ${item.product_id} not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prod = prodRes.rows[0];
|
||||||
|
const mrp = parseFloat(prod.mrp) || 0;
|
||||||
|
const dpco = parseFloat(prod.dpco_ceiling);
|
||||||
|
if (!isNaN(dpco) && mrp > dpco) {
|
||||||
|
throw {
|
||||||
|
status: 400,
|
||||||
|
error: 'DPCO_CEILING_EXCEEDED',
|
||||||
|
message: `Billed price for ${prod.brand_name} exceeds DPCO ceiling of ${dpco}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalAmount += mrp * item.qty;
|
||||||
|
|
||||||
|
// FEFO Allocation: Order by expiry ASC
|
||||||
|
const { rows: batches } = await client.query(`
|
||||||
|
SELECT batch_no, qty_on_hand, expiry
|
||||||
|
FROM batches
|
||||||
|
WHERE product_id = $1 AND qty_on_hand > 0 AND expiry > CURRENT_DATE
|
||||||
|
ORDER BY expiry ASC
|
||||||
|
FOR UPDATE
|
||||||
|
`, [item.product_id]);
|
||||||
|
|
||||||
|
let allocatedTotal = 0;
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
if (remainingQtyToAllocate <= 0) break;
|
||||||
|
|
||||||
|
const takeQty = Math.min(batch.qty_on_hand, remainingQtyToAllocate);
|
||||||
|
remainingQtyToAllocate -= takeQty;
|
||||||
|
allocatedTotal += takeQty;
|
||||||
|
|
||||||
|
// Decrement stock in DB (DB CHECK constraint ensures qty_on_hand >= 0)
|
||||||
|
await client.query(`
|
||||||
|
UPDATE batches
|
||||||
|
SET qty_on_hand = qty_on_hand - $1
|
||||||
|
WHERE product_id = $2 AND batch_no = $3
|
||||||
|
`, [takeQty, item.product_id, batch.batch_no]);
|
||||||
|
|
||||||
|
// Write Stock Movement Audit (matches schema)
|
||||||
|
pendingQueries.push({
|
||||||
|
query: `
|
||||||
|
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id)
|
||||||
|
VALUES ($1, 'dispense', $2, $3)
|
||||||
|
`,
|
||||||
|
params: [batch.batch_no, -takeQty, dispensingId]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write H1 Register Entry if applicable
|
||||||
|
if (prod.schedule === 'H1' || prod.schedule === 'H') {
|
||||||
|
const h1Id = "h1_" + Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
pendingQueries.push({
|
||||||
|
query: `
|
||||||
|
INSERT INTO h1_registers (id, dispensing_id, patient_name, prescriber_name, drug_name, batch_no, qty)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`,
|
||||||
|
params: [h1Id, dispensingId, subjectRef, prescriberName, `${prod.brand_name} (${prod.generic_name})`, batch.batch_no, takeQty]
|
||||||
|
});
|
||||||
|
h1Entries.push(h1Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
allocations.push({
|
||||||
|
product_id: item.product_id,
|
||||||
|
batch_no: batch.batch_no,
|
||||||
|
qty: takeQty,
|
||||||
|
expiry: batch.expiry
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allocatedTotal < item.qty) {
|
||||||
|
throw {
|
||||||
|
status: 409,
|
||||||
|
error: 'INSUFFICIENT_STOCK',
|
||||||
|
details: { product_id: item.product_id, requested: item.qty, available: allocatedTotal }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the dispensing transaction with billing details now that totalAmount is calculated
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO dispensing_records (id, tenant_id, subject_ref, dispensed_by, prescription_id, total_amount, payment_method)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, [dispensingId, tenantId || 'unknown', subjectRef, actorId, prescriptionId || null, totalAmount, paymentMethod]);
|
||||||
|
|
||||||
|
// Execute accumulated stock movements and H1 registers now that dispensing_records exists
|
||||||
|
for (const pq of pendingQueries) {
|
||||||
|
await client.query(pq.query, pq.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log Overrides
|
||||||
|
for (const override of overrides) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO overrides_log (dispensing_id, code, reason, actor_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, [dispensingId, override.code, override.reason, actorId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Dispatch webhook
|
||||||
|
await webhookService.dispatchEvent('dispensing.completed', `evt_${dispensingId}`, {
|
||||||
|
dispensing_id: dispensingId,
|
||||||
|
subject_ref: subjectRef,
|
||||||
|
occurred_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispensing_id: dispensingId,
|
||||||
|
batch_allocations: allocations,
|
||||||
|
h1_register_entry_ids: h1Entries,
|
||||||
|
invoice_id: "inv_" + Math.floor(Math.random() * 10000)
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDispensingHistory() {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
d.id as dispensing_id,
|
||||||
|
d.subject_ref as patient_name,
|
||||||
|
d.dispensed_by,
|
||||||
|
d.created_at,
|
||||||
|
d.prescription_id,
|
||||||
|
d.total_amount,
|
||||||
|
d.payment_method,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'batch_no', sm.batch_no,
|
||||||
|
'qty', ABS(sm.qty_delta),
|
||||||
|
'brand_name', p.brand_name,
|
||||||
|
'strength', p.strength
|
||||||
|
)
|
||||||
|
) as items
|
||||||
|
FROM dispensing_records d
|
||||||
|
JOIN stock_movements sm ON sm.reference_id = d.id
|
||||||
|
JOIN batches b ON b.batch_no = sm.batch_no
|
||||||
|
JOIN products p ON p.id = b.product_id
|
||||||
|
GROUP BY d.id
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(query);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getH1Register() {
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM h1_registers
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(query);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
|
||||||
|
|
||||||
|
// POST /v1/auth/token
|
||||||
|
router.post('/token', (req, res) => {
|
||||||
|
const { grant_type, client_id, client_secret } = req.body;
|
||||||
|
|
||||||
|
if (grant_type !== 'client_credentials') {
|
||||||
|
return res.status(400).json({ error: 'unsupported_grant_type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognize our specific external tenants
|
||||||
|
const validClients = ['jayam_web_ai', 'telegaruda'];
|
||||||
|
|
||||||
|
if (client_id && validClients.includes(client_id) && client_secret) {
|
||||||
|
// Generate a secure signed token specifically for this hospital
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
tenantId: client_id,
|
||||||
|
scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read']
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '8h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
access_token: token,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 28800,
|
||||||
|
scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ error: 'invalid_client' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth';
|
||||||
|
import { requireHeaders } from '../../middleware/headers';
|
||||||
|
import { requireIdempotency } from '../../middleware/idempotency';
|
||||||
|
import { InventoryService } from './service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const service = new InventoryService();
|
||||||
|
|
||||||
|
// POST /v1/inventory/grn
|
||||||
|
router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const actorId = req.actorId as string;
|
||||||
|
const result = await service.receiveGoods(req.body, actorId);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 422) {
|
||||||
|
return res.status(422).json(error);
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/inventory/expiring
|
||||||
|
router.get('/expiring', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const days = req.query.days ? parseInt(req.query.days as string) : 30;
|
||||||
|
const batches = await service.getExpiringBatches(days);
|
||||||
|
res.status(200).json({ batches });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/inventory/low-stock
|
||||||
|
router.get('/low-stock', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const threshold = req.query.threshold ? parseInt(req.query.threshold as string) : 50;
|
||||||
|
const batches = await service.getLowStockBatches(threshold);
|
||||||
|
res.status(200).json({ batches });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/inventory/adjustments
|
||||||
|
router.post('/adjustments', requireAuth, requireHeaders, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await service.adjustStock(req.body, req.actorId as string);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/inventory/recall/:batch_no
|
||||||
|
router.get('/recall/:batch_no', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await service.recallTrace(req.params.batch_no as string);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { pool } from '../../db/pool';
|
||||||
|
|
||||||
|
export class InventoryService {
|
||||||
|
async adjustStock(payload: any, actorId: string) {
|
||||||
|
const { batch_no, qty_delta, reason_code, notes } = payload;
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
UPDATE batches
|
||||||
|
SET qty_on_hand = qty_on_hand + $1
|
||||||
|
WHERE batch_no = $2
|
||||||
|
`, [qty_delta, batch_no]);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO inventory_adjustments (batch_no, qty_delta, reason_code, notes, approved_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, [batch_no, qty_delta, reason_code, notes, actorId]);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id)
|
||||||
|
VALUES ($1, 'adjustment', $2, 'adj_' || extract(epoch from now()))
|
||||||
|
`, [batch_no, qty_delta]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { status: 'success', batch_no, adjusted: qty_delta };
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recallTrace(batch_no: string) {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT d.id as dispensing_id, d.tenant_id, d.subject_ref, sm.qty_delta as qty, d.created_at as dispensed_at
|
||||||
|
FROM stock_movements sm
|
||||||
|
JOIN dispensing_records d ON sm.reference_id = d.id
|
||||||
|
WHERE sm.batch_no = $1 AND sm.movement_type = 'dispense'
|
||||||
|
`, [batch_no]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
batch_no,
|
||||||
|
dispensings: rows.map(r => ({
|
||||||
|
dispensing_id: r.dispensing_id,
|
||||||
|
tenant_id: r.tenant_id,
|
||||||
|
subject_ref: r.subject_ref,
|
||||||
|
qty: Math.abs(r.qty),
|
||||||
|
dispensed_at: r.dispensed_at
|
||||||
|
})),
|
||||||
|
total_affected: rows.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExpiringBatches(daysThreshold: number = 30) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
b.batch_no,
|
||||||
|
b.product_id,
|
||||||
|
p.brand_name,
|
||||||
|
b.qty_on_hand,
|
||||||
|
b.expiry,
|
||||||
|
(b.expiry - CURRENT_DATE) as days_to_expiry
|
||||||
|
FROM batches b
|
||||||
|
JOIN products p ON b.product_id = p.id
|
||||||
|
WHERE b.qty_on_hand > 0
|
||||||
|
AND b.expiry <= CURRENT_DATE + $1::int
|
||||||
|
ORDER BY b.expiry ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [daysThreshold]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLowStockBatches(qtyThreshold: number = 50) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
b.batch_no,
|
||||||
|
b.product_id,
|
||||||
|
p.brand_name,
|
||||||
|
b.qty_on_hand,
|
||||||
|
b.expiry,
|
||||||
|
(b.expiry - CURRENT_DATE) as days_to_expiry
|
||||||
|
FROM batches b
|
||||||
|
JOIN products p ON b.product_id = p.id
|
||||||
|
WHERE b.qty_on_hand > 0
|
||||||
|
AND b.qty_on_hand <= $1::int
|
||||||
|
ORDER BY b.qty_on_hand ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [qtyThreshold]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async receiveGoods(payload: any, actorId: string) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const batchesCreated = [];
|
||||||
|
const grnId = "grn_" + Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
for (const line of payload.lines) {
|
||||||
|
// Validate expiry
|
||||||
|
if (new Date(line.expiry) <= new Date()) {
|
||||||
|
throw {
|
||||||
|
status: 422,
|
||||||
|
error: 'INVALID_EXPIRY',
|
||||||
|
details: { batch_no: line.batch_no, expiry: line.expiry, reason: 'expiry_in_past' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert or Update Batch (Upsert)
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (batch_no) DO UPDATE
|
||||||
|
SET qty_on_hand = batches.qty_on_hand + EXCLUDED.qty_on_hand,
|
||||||
|
purchase_price = EXCLUDED.purchase_price
|
||||||
|
`, [line.batch_no, line.product_id, line.expiry, line.qty, line.purchase_price]);
|
||||||
|
|
||||||
|
// Log Receipt Movement
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id)
|
||||||
|
VALUES ($1, 'receipt', $2, $3)
|
||||||
|
`, [line.batch_no, line.qty, grnId]);
|
||||||
|
|
||||||
|
batchesCreated.push(line.batch_no);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
grn_id: grnId,
|
||||||
|
batches_created: batchesCreated,
|
||||||
|
three_way_match: { status: "matched", discrepancies: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { requireAuth } from '../../middleware/auth';
|
||||||
|
import { PrescriptionService } from './service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const service = new PrescriptionService();
|
||||||
|
|
||||||
|
// Setup Multer for memory storage (for uploading to S3)
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
|
// POST /v1/rx
|
||||||
|
router.post('/', requireAuth, upload.single('image'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'MISSING_IMAGE', message: 'No image file uploaded.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorId = req.actorId;
|
||||||
|
const result = await service.uploadPrescription(req.file, actorId!);
|
||||||
|
|
||||||
|
res.status(202).json(result); // 202 Accepted (processing asynchronously)
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/prescriptions
|
||||||
|
router.get('/', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const prescriptions = await service.getAllPrescriptions();
|
||||||
|
res.status(200).json({ prescriptions });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /v1/prescriptions/:id
|
||||||
|
router.get('/:id', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const record = await service.getPrescription(req.params.id as string);
|
||||||
|
if (!record) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||||
|
|
||||||
|
res.status(200).json(record);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /v1/prescriptions/:id/confirm
|
||||||
|
router.post('/:id/confirm', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const actorId = req.actorId;
|
||||||
|
const result = await service.confirmPrescription(req.params.id as string, req.body, actorId!);
|
||||||
|
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 422 || error.status === 400 || error.status === 404) {
|
||||||
|
return res.status(error.status).json(error);
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { pool } from '../../db/pool';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
let s3Client: S3Client | null = null;
|
||||||
|
if (process.env.S3_KEY && process.env.S3_SECRET) {
|
||||||
|
s3Client = new S3Client({
|
||||||
|
region: process.env.S3_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_KEY,
|
||||||
|
secretAccessKey: process.env.S3_SECRET
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrescriptionService {
|
||||||
|
|
||||||
|
// Hash file buffer to ensure provable unalterability
|
||||||
|
hashBuffer(buffer: Buffer): string {
|
||||||
|
const hashSum = crypto.createHash('sha256');
|
||||||
|
hashSum.update(buffer);
|
||||||
|
return hashSum.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadPrescription(file: Express.Multer.File, actorId: string) {
|
||||||
|
const hash = this.hashBuffer(file.buffer);
|
||||||
|
const id = "rx_" + Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
|
// Construct S3 key
|
||||||
|
const fileName = `${Date.now()}-${file.originalname}`;
|
||||||
|
let s3Url = '';
|
||||||
|
|
||||||
|
if (process.env.S3_KEY && process.env.S3_SECRET) {
|
||||||
|
const s3Key = `${process.env.S3_BASE_PATH || ''}${fileName}`;
|
||||||
|
s3Url = `${process.env.S3_PUBLIC_BASE_PATH || ''}${s3Key}`;
|
||||||
|
|
||||||
|
// Upload to S3
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: file.buffer,
|
||||||
|
ContentType: file.mimetype
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client!.send(command);
|
||||||
|
} else {
|
||||||
|
console.warn("S3 Credentials not found, mocking S3 upload.");
|
||||||
|
s3Url = `https://mock-s3-bucket.s3.amazonaws.com/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO prescriptions (id, image_path, image_hash, status)
|
||||||
|
VALUES ($1, $2, $3, 'processing')
|
||||||
|
`, [id, s3Url, hash]);
|
||||||
|
|
||||||
|
// Enqueue async extraction job
|
||||||
|
this.enqueueExtraction(id, file);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { id, status: 'processing', hash };
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time Background Vision Model Extraction using Gemini
|
||||||
|
async enqueueExtraction(id: string, file: Express.Multer.File) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { GoogleGenAI } = require('@google/genai');
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||||
|
|
||||||
|
const prompt = `You are an AI assistant for a pharmacy.
|
||||||
|
Extract the information from this handwritten or printed medical prescription.
|
||||||
|
Return ONLY a JSON response in the following format, with NO markdown formatting, NO backticks, NO markdown JSON block, just the raw JSON object string:
|
||||||
|
{
|
||||||
|
"prescriber": { "name": "string", "registration_number": "string" },
|
||||||
|
"patient": { "name": "string", "age": number },
|
||||||
|
"image_quality": "good" | "poor",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"verbatim": "string (the exact written text)",
|
||||||
|
"parsed": { "brand": "string", "form": "tablet/capsule/syrup/etc", "dose": "string (e.g. 1-0-1)", "qty": number },
|
||||||
|
"confidence": number (0.0 to 1.0),
|
||||||
|
"confusable_with": ["string"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: 'gemini-3.6-flash',
|
||||||
|
contents: [
|
||||||
|
prompt,
|
||||||
|
{
|
||||||
|
inlineData: {
|
||||||
|
data: file.buffer.toString("base64"),
|
||||||
|
mimeType: file.mimetype
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawText = response.text || "{}";
|
||||||
|
// Clean up markdown if the AI includes it by mistake
|
||||||
|
const cleanJsonText = rawText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||||
|
const extractedData = JSON.parse(cleanJsonText);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE prescriptions
|
||||||
|
SET status = 'pending_review', extracted_data = $1
|
||||||
|
WHERE id = $2
|
||||||
|
`, [extractedData, id]);
|
||||||
|
|
||||||
|
console.log(`[AI Scan Complete] Prescription ${id} processed successfully.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Extraction job failed for", id, err);
|
||||||
|
// Fallback to error status
|
||||||
|
try {
|
||||||
|
await pool.query(`UPDATE prescriptions SET status = 'error' WHERE id = $1`, [id]);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}, 100); // slight delay to allow response to return to client first
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrescription(id: string) {
|
||||||
|
const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1`, [id]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPrescriptions() {
|
||||||
|
const { rows } = await pool.query(`SELECT * FROM prescriptions ORDER BY created_at DESC`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPrescription(id: string, confirmedData: any, actorId: string) {
|
||||||
|
// Human Gate 1: Validate that unconfirmed fields are explicitly reviewed
|
||||||
|
const { rows } = await pool.query(`SELECT status, extracted_data FROM prescriptions WHERE id = $1`, [id]);
|
||||||
|
const record = rows[0];
|
||||||
|
|
||||||
|
if (!record) throw { status: 404, error: 'NOT_FOUND' };
|
||||||
|
if (record.status !== 'pending_review') throw { status: 400, error: 'INVALID_STATUS' };
|
||||||
|
|
||||||
|
const originalData = record.extracted_data;
|
||||||
|
|
||||||
|
// Check if any low confidence items were not explicitly reviewed
|
||||||
|
for (let i = 0; i < originalData.items.length; i++) {
|
||||||
|
const origItem = originalData.items[i];
|
||||||
|
if (origItem.confidence < 0.85 || origItem.confusable_with.length > 0) {
|
||||||
|
// The pharmacist must supply a confirmed item for this index
|
||||||
|
if (!confirmedData.items || !confirmedData.items[i] || !confirmedData.items[i].explicitly_confirmed) {
|
||||||
|
throw {
|
||||||
|
status: 422,
|
||||||
|
error: 'MISSING_CONFIRMATION',
|
||||||
|
message: `Item at index ${i} requires explicit positive selection due to low confidence or confusable candidates.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE prescriptions
|
||||||
|
SET status = 'confirmed', confirmed_data = $1
|
||||||
|
WHERE id = $2
|
||||||
|
`, [confirmedData, id]);
|
||||||
|
|
||||||
|
return { id, status: 'confirmed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'test_secret_for_webhooks';
|
||||||
|
// For demo purposes, we log it or hit a dummy URL.
|
||||||
|
const WEBHOOK_URL = process.env.WEBHOOK_URL || 'http://localhost:8081/webhooks/pharmacy';
|
||||||
|
|
||||||
|
export class WebhookService {
|
||||||
|
async dispatchEvent(event: string, idempotencyKey: string, payload: any) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const bodyString = JSON.stringify(payload);
|
||||||
|
const dataToSign = `${timestamp}.${bodyString}`;
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', WEBHOOK_SECRET)
|
||||||
|
.update(dataToSign)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const signatureHeader = `t=${timestamp},v1=${signature}`;
|
||||||
|
|
||||||
|
console.log(`[WEBHOOK] Dispatching event: ${event} (Idempotency: ${idempotencyKey})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Assuming Node 20+, native fetch is available
|
||||||
|
const response = await fetch(WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Pharmacy-Event': event,
|
||||||
|
'X-Pharmacy-Signature': signatureHeader,
|
||||||
|
'Idempotency-Key': idempotencyKey,
|
||||||
|
},
|
||||||
|
body: bodyString,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[WEBHOOK] Dispatch failed with status: ${response.status}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[WEBHOOK] Successfully dispatched event: ${event}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[WEBHOOK] Failed to dispatch webhook: ${error.message}`);
|
||||||
|
// In a real system, this would queue for retry with exponential backoff.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webhookService = new WebhookService();
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
async function testAPI() {
|
||||||
|
console.log("1. Requesting Token for jayam_web_ai...");
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: "jayam_web_ai",
|
||||||
|
client_secret: "my_secret_key"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokenData.access_token) {
|
||||||
|
console.error("Failed to get token!", tokenData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Successfully received real JWT token!");
|
||||||
|
console.log("Token:", tokenData.access_token.substring(0, 30) + "...\n");
|
||||||
|
|
||||||
|
console.log("2. Using Token to search for drugs (Catalogue API)...");
|
||||||
|
|
||||||
|
const searchResponse = await fetch("http://localhost:8080/v1/drugs/search?q=Losartan", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${tokenData.access_token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_1234"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchData = await searchResponse.json();
|
||||||
|
|
||||||
|
console.log(`✅ API Response Status: ${searchResponse.status}`);
|
||||||
|
console.log("Drug Search Results:");
|
||||||
|
console.log(JSON.stringify(searchData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
testAPI();
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
|
||||||
|
|
||||||
|
pool.query("SELECT id, status, extracted_data FROM prescriptions ORDER BY created_at DESC LIMIT 2")
|
||||||
|
.then(res => {
|
||||||
|
console.log(res.rows);
|
||||||
|
pool.end();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
pool.end();
|
||||||
|
});
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
|
||||||
|
|
||||||
|
pool.query("SELECT * FROM dispensing_records ORDER BY created_at DESC LIMIT 5")
|
||||||
|
.then(res => {
|
||||||
|
console.log('dispensing_records:', res.rows);
|
||||||
|
return pool.query("SELECT * FROM stock_movements ORDER BY created_at DESC LIMIT 5");
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
console.log('stock_movements:', res.rows);
|
||||||
|
pool.end();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
pool.end();
|
||||||
|
});
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
|
||||||
|
|
||||||
|
pool.query("SELECT id, brand_name FROM products")
|
||||||
|
.then(res => {
|
||||||
|
console.log('products:', res.rows);
|
||||||
|
pool.end();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
pool.end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
async function testDispense() {
|
||||||
|
console.log("1. Logging in as jayam_web_ai...");
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: "jayam_web_ai",
|
||||||
|
client_secret: "my_secret_key"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = (await tokenResponse.json()).access_token;
|
||||||
|
|
||||||
|
console.log("\n2. Running Precheck for a pregnant patient...");
|
||||||
|
const precheckResponse = await fetch("http://localhost:8080/v1/orders/ord_123/precheck", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_1234",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
clinical_context: {
|
||||||
|
pregnancy: { status: "pregnant" }
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{ product_id: "prd_4410", qty: 30 } // Losar 50
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Precheck Results:", JSON.stringify(await precheckResponse.json(), null, 2));
|
||||||
|
|
||||||
|
console.log("\n3. Executing Dispense transaction (requesting 30 units of Losar 50)...");
|
||||||
|
const dispenseResponse = await fetch("http://localhost:8080/v1/orders/ord_123/dispense", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_1234",
|
||||||
|
"Idempotency-Key": "random-uuid-here-" + Date.now(),
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject_ref: "pat_123",
|
||||||
|
items: [
|
||||||
|
{ product_id: "prd_4410", qty: 30 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Dispense Status:", dispenseResponse.status);
|
||||||
|
console.log("Dispense Results:", JSON.stringify(await dispenseResponse.json(), null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
testDispense();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { GoogleGenAI } = require('@google/genai');
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||||
|
ai.models.generateContent({
|
||||||
|
model: 'gemini-3.6-flash',
|
||||||
|
contents: [
|
||||||
|
'Hello',
|
||||||
|
{
|
||||||
|
inlineData: {
|
||||||
|
data: Buffer.from('hello').toString("base64"),
|
||||||
|
mimeType: 'text/plain'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}).then(res => {
|
||||||
|
console.log('Success:', res.text);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
});
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
import { pool } from './src/db/pool';
|
||||||
|
|
||||||
|
async function testGRN() {
|
||||||
|
console.log("1. Requesting Token...");
|
||||||
|
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: "jayam_web_ai",
|
||||||
|
client_secret: "my_secret_key"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
const token = tokenData.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error("No token received", tokenData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchNo = "BAT-" + Date.now();
|
||||||
|
|
||||||
|
console.log(`2. Sending GRN for Losar 50 (prd_4410), Batch: ${batchNo}...`);
|
||||||
|
const grnResponse = await fetch("http://localhost:8080/v1/inventory/grn", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_admin",
|
||||||
|
"idempotency-key": "idemp-" + Date.now()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
batch_no: batchNo,
|
||||||
|
product_id: "prd_4410",
|
||||||
|
expiry: "2027-12-31",
|
||||||
|
qty: 500,
|
||||||
|
purchase_price: "50.00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const grnData = await grnResponse.json();
|
||||||
|
console.log("GRN Response Status:", grnResponse.status);
|
||||||
|
console.log("GRN Response Data:", grnData);
|
||||||
|
|
||||||
|
console.log("\n3. Verifying Database...");
|
||||||
|
const batchRes = await pool.query("SELECT * FROM batches WHERE batch_no = $1", [batchNo]);
|
||||||
|
console.log("Batch in DB:", batchRes.rows);
|
||||||
|
|
||||||
|
const stockMoveRes = await pool.query("SELECT * FROM stock_movements WHERE batch_no = $1", [batchNo]);
|
||||||
|
console.log("Stock Movements in DB:", stockMoveRes.rows);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
testGRN();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 8080,
|
||||||
|
path: '/v1/orders/history',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer test',
|
||||||
|
'X-Actor-Id': 'act_admin'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, res => {
|
||||||
|
console.log(`statusCode: ${res.statusCode}`);
|
||||||
|
let data = '';
|
||||||
|
res.on('data', d => {
|
||||||
|
data += d;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { GoogleGenAI } = require('@google/genai');
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||||
|
ai.models.list().then(res => {
|
||||||
|
console.log(res);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
});
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
async function testPhase2() {
|
||||||
|
console.log("1. Creating dummy image file...");
|
||||||
|
const dummyFilePath = path.join(__dirname, 'dummy_rx.jpg');
|
||||||
|
fs.writeFileSync(dummyFilePath, 'dummy image content');
|
||||||
|
|
||||||
|
console.log("2. Requesting Token...");
|
||||||
|
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: "jayam_web_ai",
|
||||||
|
client_secret: "my_secret_key"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
const token = tokenData.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error("No token received", tokenData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("3. Uploading Prescription (POST /v1/prescriptions/upload)...");
|
||||||
|
|
||||||
|
// Use fetch with FormData to upload file
|
||||||
|
const formData = new FormData();
|
||||||
|
const fileBlob = new Blob([fs.readFileSync(dummyFilePath)], { type: 'image/jpeg' });
|
||||||
|
formData.append('image', fileBlob, 'dummy_rx.jpg');
|
||||||
|
|
||||||
|
const uploadResponse = await fetch("http://localhost:8080/v1/prescriptions/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_admin"
|
||||||
|
},
|
||||||
|
body: formData as any
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadData = await uploadResponse.json();
|
||||||
|
console.log("Upload Status:", uploadResponse.status);
|
||||||
|
console.log("Upload Data:", uploadData);
|
||||||
|
const rxId = uploadData.id;
|
||||||
|
|
||||||
|
if (uploadResponse.status !== 202) {
|
||||||
|
console.error("Upload failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n4. Polling for Extraction Results (waiting 6 seconds)...");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 6000));
|
||||||
|
|
||||||
|
const pollResponse = await fetch(`http://localhost:8080/v1/prescriptions/${rxId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_admin"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pollData = await pollResponse.json();
|
||||||
|
console.log("Poll Status:", pollResponse.status);
|
||||||
|
console.log("Image Path (S3 URL):", pollData.image_path);
|
||||||
|
console.log("Extracted Data:", JSON.stringify(pollData.extracted_data, null, 2));
|
||||||
|
|
||||||
|
console.log("\n5. Testing Confirmation - Human Gate 1");
|
||||||
|
console.log("Attempting to confirm without explicit selection on low-confidence item...");
|
||||||
|
|
||||||
|
const badConfirmPayload = {
|
||||||
|
items: [
|
||||||
|
{ explicitly_confirmed: true },
|
||||||
|
{ explicitly_confirmed: false } // Fails Gate 1
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const badConfirmResponse = await fetch(`http://localhost:8080/v1/prescriptions/${rxId}/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_admin"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(badConfirmPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Bad Confirm Status (Expect 422):", badConfirmResponse.status);
|
||||||
|
console.log("Bad Confirm Response:", await badConfirmResponse.json());
|
||||||
|
|
||||||
|
console.log("\nAttempting to confirm properly...");
|
||||||
|
const goodConfirmPayload = {
|
||||||
|
items: [
|
||||||
|
{ explicitly_confirmed: true },
|
||||||
|
{ explicitly_confirmed: true, parsed: { brand: "Hydralazine 25mg" } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const goodConfirmResponse = await fetch(`http://localhost:8080/v1/prescriptions/${rxId}/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_ph_admin"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(goodConfirmPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Good Confirm Status (Expect 200):", goodConfirmResponse.status);
|
||||||
|
console.log("Good Confirm Response:", await goodConfirmResponse.json());
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
testPhase2();
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
async function testRemaining() {
|
||||||
|
console.log("1. Logging in as jayam_web_ai...");
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: "jayam_web_ai",
|
||||||
|
client_secret: "my_secret_key"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = (await tokenResponse.json()).access_token;
|
||||||
|
|
||||||
|
console.log("\n2. Executing Goods Receipt Note (GRN)...");
|
||||||
|
const grnResponse = await fetch("http://localhost:8080/v1/inventory/grn", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_mgr_1234",
|
||||||
|
"Idempotency-Key": "grn-uuid-" + Date.now(),
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
supplier_id: "sup_120",
|
||||||
|
invoice_no: "INV/2026/9999",
|
||||||
|
invoice_date: "2026-07-24",
|
||||||
|
po_ref: "po_8891",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
product_id: "prd_4410",
|
||||||
|
batch_no: "B9999-" + Date.now(),
|
||||||
|
expiry: "2028-12-31",
|
||||||
|
qty: 500,
|
||||||
|
purchase_price: 54.20,
|
||||||
|
mrp: 78.50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("GRN Status:", grnResponse.status);
|
||||||
|
console.log("GRN Results:", JSON.stringify(await grnResponse.json(), null, 2));
|
||||||
|
|
||||||
|
console.log("\n3. Fetching Audit Logs...");
|
||||||
|
const auditResponse = await fetch("http://localhost:8080/v1/audit/logs", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`,
|
||||||
|
"X-Actor-Id": "usr_mgr_1234"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Audit Status:", auditResponse.status);
|
||||||
|
const auditData = await auditResponse.json();
|
||||||
|
console.log("Found Stock Movements:", auditData.stock_movements.length);
|
||||||
|
console.log("Latest Stock Movement:", JSON.stringify(auditData.stock_movements[0], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
testRemaining();
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { AIService } from './src/modules/ai/service';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
const s = new AIService();
|
||||||
|
s.processVoiceCommand('low stock').then(res => {
|
||||||
|
console.log("SUCCESS:", res);
|
||||||
|
process.exit(0);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("FAIL:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
async function updateDB() {
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
method VARCHAR(10),
|
||||||
|
path VARCHAR(255),
|
||||||
|
response_body JSONB,
|
||||||
|
response_status INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS order_drafts (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
subject_ref VARCHAR(100) NOT NULL,
|
||||||
|
prescription_ref VARCHAR(50),
|
||||||
|
items JSONB DEFAULT '[]'::jsonb,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_adjustments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
batch_no VARCHAR(100) REFERENCES batches(batch_no),
|
||||||
|
qty_delta INTEGER NOT NULL,
|
||||||
|
reason_code VARCHAR(50) NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
approved_by VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('Core API schema updated successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDB();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
async function updateDB() {
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS h1_registers (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
dispensing_id VARCHAR(50) REFERENCES dispensing_records(id),
|
||||||
|
patient_name VARCHAR(255) NOT NULL,
|
||||||
|
prescriber_name VARCHAR(255),
|
||||||
|
drug_name VARCHAR(255) NOT NULL,
|
||||||
|
batch_no VARCHAR(100) NOT NULL,
|
||||||
|
qty INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS hsn_code VARCHAR(20);
|
||||||
|
UPDATE products SET dpco_ceiling = 80.00 WHERE id = 'prd_4410';
|
||||||
|
UPDATE products SET dpco_ceiling = 120.00 WHERE id = 'prd_5510';
|
||||||
|
`);
|
||||||
|
console.log('Schema updated successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDB();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy image content
|
||||||
Reference in New Issue
Block a user