feat: implement dispensing module with transaction-safe stock management, FEFO allocation, and H1 compliance reporting

This commit is contained in:
2026-08-19 11:05:04 +05:30
parent fd77985cf9
commit ea49fe800f
32 changed files with 1150 additions and 53 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
DATABASE_URL=postgres://postgres:csh%402018@localhost:5432/ai_pharmacy
PORT=8080
JWT_SECRET=super_secret_pharmacy_key_2026
JWT_SECRET=caa4e8c1e05f10194b15ac3064df6289bb431b4daa3d1db6a469821212d8973f
# S3 CONFIGURATION
S3_KEY=AKIA3GRU7BB33WCCTN4V
@@ -11,5 +11,5 @@ S3_BASE_PATH=temp/
S3_PUBLIC_BASE_PATH=https://a2zschoolfolio.s3.ap-south-1.amazonaws.com/
GEMINI_API_KEY=AQ.Ab8RN6KkBPhZo9kmwV-mFBtriT3LF8vPWenXNfA2jSyVWWwMMQ
OPENAI_API_KEY=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
+54
View File
@@ -77,6 +77,60 @@
]
}
}
},
{
"name": "Register Pharmacist",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"test_pharma\",\n \"password\": \"secret123\"\n}"
},
"url": {
"raw": "{{base_url}}/v1/auth/register",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"auth",
"register"
]
}
}
},
{
"name": "Pharmacist Login",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"test_pharma\",\n \"password\": \"secret123\"\n}"
},
"url": {
"raw": "{{base_url}}/v1/auth/login",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"auth",
"login"
]
}
}
}
]
},
+21
View File
@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const pool_1 = require("./src/db/pool");
async function migrate() {
try {
await pool_1.pool.query('ALTER TABLE users ADD COLUMN profile_image_url TEXT');
console.log("Migration successful");
}
catch (err) {
if (err.message.includes('already exists')) {
console.log("Column already exists");
}
else {
console.error(err);
}
}
finally {
process.exit(0);
}
}
migrate();
+18
View File
@@ -0,0 +1,18 @@
import { pool } from './src/db/pool';
async function migrate() {
try {
await pool.query('ALTER TABLE users ADD COLUMN profile_image_url TEXT');
console.log("Migration successful");
} catch (err: any) {
if (err.message.includes('already exists')) {
console.log("Column already exists");
} else {
console.error(err);
}
} finally {
process.exit(0);
}
}
migrate();
+3 -2
View File
@@ -6,8 +6,9 @@ const pool = new Pool({
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.');
await pool.query('ALTER TABLE stock_movements ADD COLUMN IF NOT EXISTS reason_code VARCHAR(100)');
await pool.query('ALTER TABLE stock_movements ADD COLUMN IF NOT EXISTS notes TEXT');
console.log('Successfully added columns.');
} catch (err) {
console.error('Error:', err);
} finally {
+7
View File
@@ -0,0 +1,7 @@
const { Client } = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
const res = await client.query("SELECT brand_name, mrp, pack_size FROM products WHERE brand_name LIKE '%Novamox%'");
console.log(res.rows);
process.exit(0);
}).catch(console.error);
+8
View File
@@ -0,0 +1,8 @@
const { Client } = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
await client.query("UPDATE products SET mrp = 30.00 WHERE brand_name = 'Dolo'");
await client.query("UPDATE products SET mrp = 115.00 WHERE brand_name = 'Benadryl DR'");
console.log('Prices updated');
process.exit(0);
}).catch(console.error);
+30
View File
@@ -0,0 +1,30 @@
const { Client } = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
try {
// 1. Update the pack_size of Losar 50 to 10
await client.query("UPDATE products SET pack_size = 10 WHERE id = 'prd_4410'");
// 2. Fix all old dispensing records that were calculated with pack_size=1
// For every dispensing record, we recalculate the total amount.
const res = await client.query(`
SELECT d.id,
SUM((p.mrp / COALESCE(NULLIF(p.pack_size, 0), 1)) * ABS(sm.qty_delta)) as new_total
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
`);
for (const row of res.rows) {
await client.query("UPDATE dispensing_records SET total_amount = $1 WHERE id = $2", [row.new_total, row.id]);
}
console.log("Updated pack_size to 10 for prd_4410 and corrected all historical total amounts.");
} catch (err) {
console.error('Error fixing history:', err);
}
process.exit(0);
}).catch(console.error);
+15
View File
@@ -0,0 +1,15 @@
const { Client } = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
try {
await client.query("ALTER TABLE products ADD COLUMN pack_size INTEGER DEFAULT 1;");
console.log('Added pack_size column to products table.');
} catch (err) {
if (err.code === '42701') {
console.log('pack_size column already exists.');
} else {
console.error(err);
}
}
process.exit(0);
}).catch(console.error);
+26
View File
@@ -0,0 +1,26 @@
const { Client } = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
try {
const res = await client.query('SELECT DISTINCT tenant_id FROM products WHERE tenant_id IS NOT NULL');
const tenants = res.rows.map(r => r.tenant_id);
if (!tenants.includes('act_admin')) tenants.push('act_admin');
// Expiry date 2 years from now
const expiry = new Date(Date.now() + 2 * 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
for (const t of tenants) {
const batchNo = 'B_FIX_' + t.substring(0,8) + '_' + Date.now();
await client.query(`
INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price, tenant_id)
VALUES ($1, 'prd_4410', $2, 1000, 50.00, $3)
`, [batchNo, expiry, t]);
console.log(`Added stock for tenant ${t}`);
}
} catch (err) {
console.error('Error adding stock:', err);
}
process.exit(0);
}).catch(console.error);
+35
View File
@@ -0,0 +1,35 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function migrate() {
try {
const DEFAULT_TENANT = 'act_admin'; // Fallback tenant for existing data
await pool.query(`
ALTER TABLE prescriptions ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(50);
UPDATE prescriptions SET tenant_id = '${DEFAULT_TENANT}' WHERE tenant_id IS NULL;
ALTER TABLE batches ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(50);
UPDATE batches SET tenant_id = '${DEFAULT_TENANT}' WHERE tenant_id IS NULL;
ALTER TABLE patients ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(50);
UPDATE patients SET tenant_id = '${DEFAULT_TENANT}' WHERE tenant_id IS NULL;
ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(50);
UPDATE suppliers SET tenant_id = '${DEFAULT_TENANT}' WHERE tenant_id IS NULL;
ALTER TABLE order_drafts ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(50);
UPDATE order_drafts SET tenant_id = '${DEFAULT_TENANT}' WHERE tenant_id IS NULL;
`);
console.log('Multi-tenant migration completed successfully');
} catch (err) {
console.error('Error during migration:', err);
} finally {
pool.end();
}
}
migrate();
+324 -34
View File
@@ -10,13 +10,18 @@
"license": "ISC",
"dependencies": {
"@aws-sdk/client-s3": "^3.1094.0",
"@aws-sdk/s3-request-presigner": "^3.1101.0",
"@google/genai": "^2.13.0",
"@types/bcrypt": "^6.0.0",
"bcrypt": "^6.0.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",
"openai": "^7.1.0",
"pdfkit": "^0.19.1",
"pg": "^8.22.0",
"redis": "^6.1.0"
},
@@ -26,6 +31,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^26.1.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.20.0",
"ts-node-dev": "^2.0.0",
"tsx": "^4.23.1",
@@ -71,16 +77,16 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.976.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz",
"integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==",
"version": "3.977.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz",
"integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.2",
"@aws-sdk/xml-builder": "^3.972.36",
"@aws-sdk/xml-builder": "^3.972.37",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.4",
"@smithy/signature-v4": "^5.6.5",
"@smithy/core": "^3.31.1",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
@@ -273,14 +279,31 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1101.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1101.0.tgz",
"integrity": "sha512-2raRfiex9qYrdQ60ATeEmOVVC7Do2GEDGSTnoPbgv+xyN3oB48tZeREVw0brI7AlRRvm9fygXTHmyRR0I/LskA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.4",
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz",
"integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==",
"version": "3.996.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz",
"integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.2",
"@smithy/signature-v4": "^5.6.5",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@@ -319,9 +342,9 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.36",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz",
"integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz",
"integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
@@ -847,6 +870,30 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -978,9 +1025,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.29.8",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.8.tgz",
"integrity": "sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA==",
"version": "3.31.1",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz",
"integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
@@ -1033,12 +1080,13 @@
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.9",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.9.tgz",
"integrity": "sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw==",
"version": "5.6.12",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz",
"integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@smithy/core": "^3.29.8",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@@ -1058,6 +1106,15 @@
"node": ">=18.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -1086,6 +1143,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -1187,6 +1253,16 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1696,6 +1772,20 @@
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
@@ -1785,6 +1875,24 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1871,6 +1979,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -2001,6 +2118,12 @@
"node": ">= 0.8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -2207,6 +2330,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -2264,6 +2393,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -2339,20 +2485,6 @@
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2442,6 +2574,20 @@
"node": ">=18"
}
},
"node_modules/google-auth-library/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
@@ -2643,6 +2789,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
@@ -2695,6 +2847,25 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2918,6 +3089,15 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -2956,6 +3136,17 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3008,6 +3199,39 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-7.1.0.tgz",
"integrity": "sha512-7xWJ9iO5z5u1dnIUGwoUmZHkSyrUYXX2cUxo2E/26iKFrSC8IdEak7z94d5UntU7z+S/Cid33hYymwMSab2fZQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@aws-sdk/credential-provider-node": ">=3.972.0 <4",
"@smithy/hash-node": ">=4.3.0 <5",
"@smithy/signature-v4": ">=5.4.0 <6",
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-provider-node": {
"optional": true
},
"@smithy/hash-node": {
"optional": true
},
"@smithy/signature-v4": {
"optional": true
},
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
@@ -3021,6 +3245,12 @@
"node": ">=8"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3057,6 +3287,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
@@ -3160,6 +3404,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -3344,6 +3596,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -3633,6 +3891,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3861,6 +4125,32 @@
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+6
View File
@@ -13,13 +13,18 @@
"type": "commonjs",
"dependencies": {
"@aws-sdk/client-s3": "^3.1094.0",
"@aws-sdk/s3-request-presigner": "^3.1101.0",
"@google/genai": "^2.13.0",
"@types/bcrypt": "^6.0.0",
"bcrypt": "^6.0.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",
"openai": "^7.1.0",
"pdfkit": "^0.19.1",
"pg": "^8.22.0",
"redis": "^6.1.0"
},
@@ -29,6 +34,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^26.1.1",
"@types/pdfkit": "^0.17.6",
"@types/pg": "^8.20.0",
"ts-node-dev": "^2.0.0",
"tsx": "^4.23.1",
+20
View File
@@ -0,0 +1,20 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.pool = void 0;
const pg_1 = require("pg");
const dotenv_1 = __importDefault(require("dotenv"));
dotenv_1.default.config();
exports.pool = new pg_1.Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/ai_pharmacy',
});
// Test connection
exports.pool.on('connect', () => {
console.log('Connected to the PostgreSQL database.');
});
exports.pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
+2
View File
@@ -12,6 +12,7 @@ import auditRouter from './modules/audit/router';
import prescriptionRouter from './modules/prescription/router';
import aiRouter from './modules/ai/router';
import integrationRouter from './modules/integration/router';
import uploadRouter from './modules/upload/router';
const app = express();
const PORT = process.env.PORT || 8080;
@@ -47,6 +48,7 @@ app.use('/v1/audit', auditRouter);
app.use('/v1/rx', prescriptionRouter);
app.use('/v1/ai', aiRouter);
app.use('/v1/integration', integrationRouter);
app.use('/v1/upload', uploadRouter);
// Health check
app.get('/health', (req, res) => {
+25 -4
View File
@@ -18,6 +18,7 @@ If the user asks about suppliers or distributors, use get_total_suppliers.
If the user asks to open or navigate to a page (e.g. "open prescriptions", "take me to suppliers", "show patients"), 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 search for a supplier, use search_supplier.
If the user asks to search for a patient, use search_patient.
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.
If the user asks to add a new supplier, use add_supplier.
@@ -41,6 +42,11 @@ If the user asks to add a new patient, use add_patient.`;
description: "Search for a wholesale supplier by name.",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
}},
{ type: "function", function: {
name: "search_patient",
description: "Search for a patient by name.",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
}},
{ type: "function", function: {
name: "add_medicine",
description: "Add a new medicine to the product catalog.",
@@ -137,7 +143,7 @@ If the user asks to add a new patient, use add_patient.`;
const { rows } = await pool.query(`SELECT sum(total_amount) as total FROM dispensing_records WHERE DATE(created_at) = CURRENT_DATE AND tenant_id = $1`, [actorId]);
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' AND qty_on_hand > 0 AND tenant_id = $1`, [actorId]);
const { rows } = await pool.query(`SELECT count(*) as count FROM batches WHERE expiry >= CURRENT_DATE AND expiry <= CURRENT_DATE + interval '30 days' AND qty_on_hand > 0 AND tenant_id = $1`, [actorId]);
toolData = { batches_expiring_in_30_days: rows[0].count };
actionCommand = 'VIEW_EXPIRING_STOCK';
} else if (call.name === 'get_total_patients') {
@@ -151,13 +157,14 @@ If the user asks to add a new patient, use add_patient.`;
toolData = { todays_prescriptions: rows[0].count };
} else if (call.name === 'search_medicine') {
const query = args.query || '';
const searchStr = query.replace(/\s+/g, '');
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
WHERE REPLACE(p.brand_name, ' ', '') ILIKE $1
GROUP BY p.brand_name`,
[`%${query}%`]
[`%${searchStr}%`]
);
toolData = rows.length > 0 ? { search_results: rows } : { message: `No medicine found matching '${query}'.` };
actionCommand = 'NAVIGATE_INVENTORY';
@@ -166,13 +173,27 @@ If the user asks to add a new patient, use add_patient.`;
const { rows } = await pool.query('SELECT name, contact_number FROM suppliers WHERE name ILIKE $1', [`%${query}%`]);
toolData = rows.length > 0 ? { search_results: rows } : { message: `No supplier found matching '${query}'.` };
actionCommand = 'NAVIGATE_SUPPLIERS';
} else if (call.name === 'search_patient') {
const query = args.query || '';
const searchStr = query.replace(/\s+/g, '');
let rows = [];
try {
const res = await pool.query('SELECT id, name, phone FROM patients WHERE REPLACE(name, \' \', \'\') ILIKE $1', [`%${searchStr}%`]);
rows = res.rows;
} catch(e) {
const res = await pool.query('SELECT id, full_name, contact_number FROM patients WHERE REPLACE(full_name, \' \', \'\') ILIKE $1', [`%${searchStr}%`]);
rows = res.rows;
}
toolData = rows.length > 0 ? { search_results: rows } : { message: `No patient found matching '${query}'.` };
actionCommand = 'NAVIGATE_PATIENTS';
} else if (call.name === 'add_medicine') {
const id = `prd_${Date.now()}`;
await pool.query('INSERT INTO products (id, brand_name, form, mrp, pack_size) VALUES ($1, $2, $3, $4, $5)', [id, args.brand_name, args.form || 'tablet', args.mrp || 0, args.pack_size || 1]);
toolData = { success: true, message: `Added ${args.brand_name} successfully.` };
actionCommand = 'NAVIGATE_CATALOGUE';
} else if (call.name === 'add_stock') {
const pRes = await pool.query('SELECT id, brand_name FROM products WHERE brand_name ILIKE $1 LIMIT 1', [`%${args.product_name}%`]);
const searchStr = (args.product_name || '').replace(/\s+/g, '');
const pRes = await pool.query('SELECT id, brand_name FROM products WHERE REPLACE(brand_name, \' \', \'\') ILIKE $1 LIMIT 1', [`%${searchStr}%`]);
if (pRes.rows.length > 0) {
const batchNo = args.batch_no || `B${Date.now()}`;
const expiry = args.expiry || new Date(Date.now() + 31536000000).toISOString().split('T')[0];
+1 -1
View File
@@ -33,7 +33,7 @@ router.get('/ai-calls', requireAuth, async (req, res, next) => {
// Also grab the stock movements for full audit visibility
const { rows: stockMovements } = await pool.query(`
SELECT sm.id, sm.batch_no, sm.movement_type, sm.qty_delta, sm.reference_id, sm.created_at
SELECT sm.id, sm.batch_no, sm.movement_type, sm.qty_delta, sm.reference_id, sm.created_at, sm.reason_code, sm.notes
FROM stock_movements sm
JOIN batches b ON sm.batch_no = b.batch_no
WHERE b.tenant_id = $1
+2
View File
@@ -14,6 +14,7 @@ export class CatalogueService {
d.is_nti,
d.pregnancy_category,
p.mrp,
p.pack_size,
p.dpco_ceiling,
(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE AND tenant_id = $3) as total_stock
FROM products p
@@ -41,6 +42,7 @@ export class CatalogueService {
d.is_nti,
d.pregnancy_category,
p.mrp,
p.pack_size,
p.dpco_ceiling,
(SELECT purchase_price FROM batches WHERE product_id = p.id AND tenant_id = $2 ORDER BY expiry DESC LIMIT 1) as purchase_price,
(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE AND tenant_id = $2) as total_stock
+155
View File
@@ -0,0 +1,155 @@
import PDFDocument from 'pdfkit';
import { Writable } from 'stream';
const BRAND_COLOR = '#4F46E5'; // Indigo-600
const TEXT_COLOR = '#334155'; // Slate-700
const LIGHT_TEXT = '#64748B'; // Slate-500
const BORDER_COLOR = '#E2E8F0'; // Slate-200
export function generateInvoicePDF(record: any, stream: Writable) {
const doc = new PDFDocument({ margin: 50, size: 'A4' });
doc.pipe(stream);
// --- Header Background ---
doc
.rect(0, 0, 600, 110)
.fill(BRAND_COLOR);
// --- Header Text ---
doc
.fillColor('#FFFFFF')
.font('Helvetica-Bold')
.fontSize(28)
.text('INVOICE', 50, 40, { characterSpacing: 2 })
.fontSize(10)
.font('Helvetica')
.text(`Date: ${new Date(record.created_at).toLocaleDateString()}`, 50, 75)
.text(`Invoice #: ${record.dispensing_id.substring(0, 8).toUpperCase()}`, 50, 90);
// --- Pharmacy Details (Right Aligned in Header) ---
doc
.font('Helvetica-Bold')
.fontSize(16)
.text('AI Pharmacy Clinic', 300, 40, { align: 'right' })
.font('Helvetica')
.fontSize(10)
.text('123 Health Avenue, Med City', 300, 60, { align: 'right' })
.text('Phone: (555) 123-4567', 300, 75, { align: 'right' })
.text('Email: hello@aipharmacy.com', 300, 90, { align: 'right' });
doc.moveDown(4);
// --- Patient & Dispensing Details ---
const currentY = 150;
// Left Box
doc.fillColor(TEXT_COLOR).font('Helvetica-Bold').fontSize(12).text('Billed To:', 50, currentY);
doc.font('Helvetica').fontSize(10).fillColor(LIGHT_TEXT)
.text(record.patient_name || 'Walk-in Patient', 50, currentY + 15)
.text(`Payment: ${record.payment_method || 'Cash'}`, 50, currentY + 30);
// Right Box
doc.fillColor(TEXT_COLOR).font('Helvetica-Bold').fontSize(12).text('Dispensed By:', 350, currentY);
doc.font('Helvetica').fontSize(10).fillColor(LIGHT_TEXT)
.text(record.dispensed_by || 'Pharmacist', 350, currentY + 15);
doc.moveDown(3);
// --- Table Header ---
const tableTop = 240;
doc
.rect(50, tableTop, 500, 25)
.fill(BRAND_COLOR);
doc
.fillColor('#FFFFFF')
.font('Helvetica-Bold')
.fontSize(10);
generateTableRow(doc, tableTop + 7, 'Item Description', 'Batch', 'Qty', 'Unit Price', 'Total');
// --- Table Rows ---
let position = tableTop + 25;
doc.font('Helvetica').fillColor(TEXT_COLOR).fontSize(10);
for (let i = 0; i < record.items.length; i++) {
const item = record.items[i];
const qty = item.qty;
const packSize = parseInt(item.pack_size, 10) || 1;
const unitPrice = parseFloat(item.mrp) / packSize;
const total = qty * unitPrice;
// Alternating row colors
if (i % 2 === 1) {
doc.rect(50, position, 500, 25).fill('#F8FAFC'); // Slate-50
}
doc.fillColor(TEXT_COLOR);
generateTableRow(
doc,
position + 7,
item.brand_name,
item.batch_no || '-',
qty.toString(),
`$${unitPrice.toFixed(2)}`,
`$${total.toFixed(2)}`
);
position += 25;
if (position > 700) {
doc.addPage();
position = 50;
}
}
// Draw bottom table border
doc.strokeColor(BORDER_COLOR).lineWidth(1).moveTo(50, position).lineTo(550, position).stroke();
// --- Totals Section ---
const subtotal = parseFloat(record.total_amount);
const grandTotal = subtotal;
position += 20;
doc.font('Helvetica-Bold').fillColor(TEXT_COLOR).fontSize(12);
doc.text('Total Amount:', 350, position, { width: 100, align: 'right' });
doc.fillColor(BRAND_COLOR).fontSize(14);
doc.text(`$${grandTotal.toFixed(2)}`, 450, position - 2, { width: 90, align: 'right' });
// --- Footer ---
const footerY = 750;
doc.strokeColor(BORDER_COLOR).lineWidth(1).moveTo(50, footerY - 20).lineTo(550, footerY - 20).stroke();
doc
.font('Helvetica')
.fontSize(9)
.fillColor(LIGHT_TEXT)
.text(
'Thank you for your business. For any questions concerning this invoice, please contact us.',
50,
footerY,
{ align: 'center', width: 500 }
);
doc.end();
}
function generateTableRow(
doc: PDFKit.PDFDocument,
y: number,
item: string,
batch: string,
qty: string,
unitCost: string,
lineTotal: string
) {
doc
.text(item, 60, y, { width: 180 })
.text(batch, 250, y, { width: 80 })
.text(qty, 330, y, { width: 50, align: 'center' })
.text(unitCost, 390, y, { width: 70, align: 'right' })
.text(lineTotal, 460, y, { width: 80, align: 'right' });
}
+18
View File
@@ -4,6 +4,7 @@ import { requireHeaders } from '../../middleware/headers';
import { requireIdempotency } from '../../middleware/idempotency';
import { DispensingService } from './service';
import { pool } from '../../db/pool';
import { generateInvoicePDF } from './pdf';
const router = Router();
const service = new DispensingService();
@@ -28,6 +29,23 @@ router.get('/h1-register', requireAuth, async (req, res, next) => {
}
});
// GET /v1/orders/:id/invoice/pdf
router.get('/:id/invoice/pdf', requireAuth, async (req, res, next) => {
try {
const record = await service.getDispensingRecordDetails(req.params.id as string, req.actorId as string);
if (!record) {
return res.status(404).json({ error: 'DISPENSING_RECORD_NOT_FOUND' });
}
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=invoice_${req.params.id}.pdf`);
generateInvoicePDF(record, res);
} catch (error) {
next(error);
}
});
// POST /v1/orders
router.post('/', requireAuth, requireHeaders, async (req, res, next) => {
try {
+32
View File
@@ -306,4 +306,36 @@ export class DispensingService {
const { rows } = await pool.query(query, [tenantId]);
return rows;
}
async getDispensingRecordDetails(id: string, tenantId: string) {
const query = `
SELECT
d.id as dispensing_id,
COALESCE(pat.full_name, 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,
'mrp', p.mrp,
'pack_size', p.pack_size
)
) as items
FROM dispensing_records d
LEFT JOIN patients pat ON pat.hospital_patient_id = d.subject_ref OR pat.id::text = d.subject_ref
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
WHERE d.id = $1 AND d.tenant_id = $2
GROUP BY d.id, pat.full_name
`;
const { rows } = await pool.query(query, [id, tenantId]);
return rows[0] || null;
}
}
+25 -2
View File
@@ -1,9 +1,13 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import multer from 'multer';
import { IdentityService } from './service';
import { requireAuth } from '../../middleware/auth';
import { S3Service } from '../upload/service';
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
const s3Service = new S3Service();
const identityService = new IdentityService();
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
@@ -19,10 +23,29 @@ router.get('/me', requireAuth, async (req, res) => {
});
// PUT /v1/auth/me (Update Profile)
router.put('/me', requireAuth, async (req, res) => {
router.put('/me', requireAuth, upload.single('profileImage'), async (req, res) => {
try {
const userId = req.actorId as string;
const profile = await identityService.updateUserProfile(userId, req.body);
const fieldsToUpdate = { ...req.body };
// Handle profile image upload
if (req.file) {
const file = req.file;
let extension = '';
const parts = file.originalname.split('.');
if (parts.length > 1) {
extension = parts[parts.length - 1];
} else {
extension = file.mimetype.split('/')[1] || 'bin';
}
const s3Res = await s3Service.diskStorage(file.buffer, file.mimetype, extension, 'profiles/');
if (s3Res.success && s3Res.url) {
fieldsToUpdate.profile_image_url = s3Res.url;
}
}
const profile = await identityService.updateUserProfile(userId, fieldsToUpdate);
res.status(200).json({ success: true, profile });
} catch (error: any) {
res.status(400).json({ error: error.message });
+6 -4
View File
@@ -80,7 +80,7 @@ export class IdentityService {
async getUserProfile(userId: string) {
const result = await pool.query(
'SELECT id, username, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address FROM users WHERE id = $1',
'SELECT id, username, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, profile_image_url FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
@@ -95,6 +95,7 @@ export class IdentityService {
gstin?: string;
pharmacist_reg_number?: string;
address?: string;
profile_image_url?: string;
}) {
// Only update allowed fields
await pool.query(
@@ -103,9 +104,10 @@ export class IdentityService {
dl_number = COALESCE($2, dl_number),
gstin = COALESCE($3, gstin),
pharmacist_reg_number = COALESCE($4, pharmacist_reg_number),
address = COALESCE($5, address)
WHERE id = $6`,
[data.pharmacy_name, data.dl_number, data.gstin, data.pharmacist_reg_number, data.address, userId]
address = COALESCE($5, address),
profile_image_url = COALESCE($6, profile_image_url)
WHERE id = $7`,
[data.pharmacy_name, data.dl_number, data.gstin, data.pharmacist_reg_number, data.address, data.profile_image_url, userId]
);
return this.getUserProfile(userId);
+1 -1
View File
@@ -20,7 +20,7 @@ router.use((req, res, next) => {
return res.status(401).json({ error: 'Unauthorized: Missing API credentials' });
}
if (client !== 'hospital-api' && client !== 'telegaruda') {
if (client !== 'hospital-api' && client !== 'pharmacy-v1-staging') {
return res.status(401).json({ error: 'Unauthorized: Invalid client ID' });
}
+3 -3
View File
@@ -19,9 +19,9 @@ export class InventoryService {
`, [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]);
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id, reason_code, notes)
VALUES ($1, 'adjustment', $2, 'adj_' || extract(epoch from now()), $3, $4)
`, [batch_no, qty_delta, reason_code, notes]);
await client.query('COMMIT');
return { status: 'success', batch_no, adjusted: qty_delta };
+60
View File
@@ -0,0 +1,60 @@
import { Request, Response } from 'express';
import { S3Service } from './service';
const s3Service = new S3Service();
export class UploadController {
static async handleUpload(req: Request, res: Response) {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file provided' });
}
const file = req.file;
// Determine extension from originalname or mimetype
let extension = '';
const parts = file.originalname.split('.');
if (parts.length > 1) {
extension = parts[parts.length - 1];
} else {
// Fallback to mime type split
extension = file.mimetype.split('/')[1] || 'bin';
}
// Optional prefix for organizing files (e.g. 'general/', 'prescriptions/')
const prefix = req.body.prefix || 'uploads/';
const result = await s3Service.diskStorage(file.buffer, file.mimetype, extension, prefix);
if (!result.success) {
return res.status(500).json(result);
}
res.status(200).json(result);
} catch (error: any) {
console.error("[UploadController] Error:", error);
res.status(500).json({ success: false, error: error.message });
}
}
static async getTemporaryUrl(req: Request, res: Response) {
try {
const { path, expiresIn } = req.query;
if (!path) {
return res.status(400).json({ success: false, error: 'Missing path parameter' });
}
const time = expiresIn ? parseInt(expiresIn as string, 10) : 3600;
const url = await s3Service.getAwsTemporaryUrl(path as string, time);
res.status(200).json({ success: true, url });
} catch (error: any) {
console.error("[UploadController] Error:", error);
res.status(500).json({ success: false, error: error.message });
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import multer from 'multer';
import { UploadController } from './controller';
import { requireAuth } from '../../middleware/auth';
const router = Router();
// Setup Multer for memory storage
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// POST /v1/upload
router.post('/', requireAuth, upload.single('file'), UploadController.handleUpload);
// GET /v1/upload/presigned
router.get('/presigned', requireAuth, UploadController.getTemporaryUrl);
export default router;
+129
View File
@@ -0,0 +1,129 @@
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import crypto from 'crypto';
let s3Client: S3Client | null = null;
if (process.env.S3_KEY && process.env.S3_SECRET) {
s3Client = new S3Client({
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_KEY,
secretAccessKey: process.env.S3_SECRET
}
});
}
export class S3Service {
/**
* Uploads a file buffer to S3 (equivalent to Api_Mentalhealth's diskStorage)
* @param buffer The file buffer
* @param mimetype The file mimetype
* @param extension The file extension (e.g. 'png', 'pdf')
* @param pathPrefix Optional path prefix (e.g. 'uploads/')
* @returns Object containing filename, fullPath, and success status
*/
async diskStorage(buffer: Buffer, mimetype: string, extension: string, pathPrefix: string = '') {
if (!s3Client) {
console.warn("S3 Credentials not found, mocking S3 upload.");
const filename = `${crypto.createHash('md5').update(Date.now().toString() + Math.random().toString()).digest('hex')}.${extension}`;
const fullPath = `${pathPrefix}${filename}`;
return { success: true, filename, fullPath, url: `https://mock-s3-bucket.s3.amazonaws.com/${fullPath}` };
}
try {
// Generate unique MD5 filename like Api_Mentalhealth does
const filename = `${crypto.createHash('md5').update(Date.now().toString() + Math.random().toString()).digest('hex')}.${extension}`;
const fullPath = `${pathPrefix}${filename}`;
const bucket = process.env.S3_BUCKET || 'default-bucket';
const region = process.env.S3_REGION || 'us-east-1';
const command = new PutObjectCommand({
Bucket: bucket,
Key: fullPath,
Body: buffer,
ContentType: mimetype,
// Set object to be public readable
ACL: 'public-read',
});
await s3Client.send(command);
// Use the provided public base path or construct the correct regional URL
const basePath = process.env.S3_PUBLIC_BASE_PATH
? (process.env.S3_PUBLIC_BASE_PATH.endsWith('/') ? process.env.S3_PUBLIC_BASE_PATH : process.env.S3_PUBLIC_BASE_PATH + '/')
: `https://${bucket}.s3.${region}.amazonaws.com/`;
return {
success: true,
filename,
fullPath,
url: `${basePath}${fullPath}`
};
} catch (error: any) {
console.error("[S3Service] Error uploading file to S3:", error);
return { success: false, error: error.message };
}
}
/**
* Generates an AWS temporary signed URL (equivalent to Api_Mentalhealth's getAwsTemporaryUrl)
* @param path The S3 object key
* @param expiresIn Expiration time in seconds
* @returns The presigned URL
*/
async getAwsTemporaryUrl(path: string, expiresIn: number = 3600) {
if (!s3Client) {
return `https://mock-s3-bucket.s3.amazonaws.com/${path}?mock_token=123`;
}
try {
const bucket = process.env.S3_BUCKET || 'default-bucket';
const command = new GetObjectCommand({
Bucket: bucket,
Key: path,
});
const signedUrl = await getSignedUrl(s3Client, command, { expiresIn });
return signedUrl;
} catch (error: any) {
console.error("[S3Service] Error generating presigned URL:", error);
throw error;
}
}
/**
* Fetches data from S3 (equivalent to Api_Mentalhealth's getDataFromDisk)
* @param path The S3 object key
* @returns The file content as a Buffer
*/
async getDataFromDisk(path: string): Promise<Buffer | false> {
if (!s3Client) {
return false;
}
try {
const bucket = process.env.S3_BUCKET || 'default-bucket';
const command = new GetObjectCommand({
Bucket: bucket,
Key: path,
});
const response = await s3Client.send(command);
if (response.Body) {
// stream to buffer
const chunks: any[] = [];
for await (let chunk of response.Body as any) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
return false;
} catch (error: any) {
console.error("[S3Service] Error fetching data from disk:", error);
return false;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { pool } from './src/db/pool';
import { DispensingService } from './src/modules/dispensing/service';
async function runTest() {
const dispensingService = new DispensingService();
const tenantId = 't1';
// 1. Create a fake product with MRP = 20, pack_size = 10
const productId = 'prd_test_' + Date.now();
await pool.query(`
INSERT INTO products (id, brand_name, mrp, pack_size, tenant_id)
VALUES ($1, 'Test Medicine Strip', 20.00, 10, $2)
`, [productId, tenantId]);
console.log(`Created product: ${productId} with MRP 20 and pack_size 10`);
// 2. Add some stock for it so it doesn't fail expiry/stock checks
const batchNo = 'B_TEST_' + Date.now();
const nextYear = new Date(Date.now() + 365*24*60*60*1000).toISOString();
await pool.query(`
INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, tenant_id)
VALUES ($1, $2, $3, 100, $4)
`, [batchNo, productId, nextYear, tenantId]);
console.log(`Added batch ${batchNo} with 100 qty`);
// 3. Test Dispensing Flow (Dispense 10 tablets)
const payload = {
subject_ref: 'Patient_123',
items: [
{ product_id: productId, qty: 10 }
]
};
try {
console.log(`\nAttempting to dispense 10 tablets...`);
const result = await dispensingService.executeDispensing(payload, 'user1', tenantId);
console.log(`\nDispensing Successful!`);
console.log(`Transaction ID: ${result.dispensing_id}`);
console.log(`Calculated Total Amount: ₹${result.total_amount}`);
if (parseFloat(result.total_amount) === 20) {
console.log('✅ TEST PASSED: 10 tablets at (20 MRP / 10 pack_size) correctly calculated as 20 rupees!');
} else {
console.log('❌ TEST FAILED: Total amount is not 20 rupees.');
}
} catch (err) {
console.error('Dispensing Error:', err);
}
process.exit(0);
}
runTest();
+30
View File
@@ -0,0 +1,30 @@
const { Client } = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
const query = `
SELECT
d.id as dispensing_id,
COALESCE(pat.full_name, d.subject_ref) as patient_name,
d.total_amount,
json_agg(
json_build_object(
'batch_no', sm.batch_no,
'qty', ABS(sm.qty_delta),
'brand_name', p.brand_name
)
) as items
FROM dispensing_records d
LEFT JOIN patients pat ON pat.hospital_patient_id = d.subject_ref OR pat.id::text = d.subject_ref
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
WHERE d.tenant_id = 'usr_1785482612908'
GROUP BY d.id, pat.full_name
ORDER BY d.created_at DESC
LIMIT 5
`;
const res = await client.query(query);
console.log(JSON.stringify(res.rows, null, 2));
process.exit(0);
}).catch(console.error);
+7
View File
@@ -0,0 +1,7 @@
const {Client} = require('pg');
const client = new Client('postgres://postgres:csh%402018@localhost:5432/ai_pharmacy');
client.connect().then(async () => {
await client.query("UPDATE batches SET qty_on_hand = 10");
console.log('Updated all batches to 10 for testing');
process.exit(0);
});
+14
View File
@@ -0,0 +1,14 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
async function run() {
try {
await pool.query("UPDATE stock_movements SET reason_code = 'system_recovery', notes = 'Recovered past adjustment' WHERE movement_type = 'adjustment' AND notes IS NULL");
console.log('Successfully updated old adjustments!');
} catch(err) {
console.error(err);
} finally {
pool.end();
}
}
run();