almost complete the api.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { pool } from './pool';
|
||||
|
||||
async function createUsersTable() {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'pharmacist',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
console.log('Users table created successfully.');
|
||||
} catch (err) {
|
||||
console.error('Error creating users table:', err);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
createUsersTable();
|
||||
+47
-1
@@ -7,6 +7,17 @@ async function initDB() {
|
||||
|
||||
console.log('Creating tables...');
|
||||
|
||||
// 0. Users / Auth Module
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) DEFAULT 'pharmacist',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// 1. Catalogue Module
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS drugs (
|
||||
@@ -28,15 +39,33 @@ async function initDB() {
|
||||
strength VARCHAR(50),
|
||||
form VARCHAR(50),
|
||||
mrp DECIMAL(10, 2),
|
||||
pack_size INTEGER DEFAULT 1,
|
||||
dpco_ceiling DECIMAL(10, 2)
|
||||
);
|
||||
`);
|
||||
|
||||
// 2. Inventory Module
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
tenant_id VARCHAR(50),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
contact_number VARCHAR(50),
|
||||
email VARCHAR(255),
|
||||
gstin VARCHAR(50),
|
||||
dl_number VARCHAR(100),
|
||||
address TEXT,
|
||||
contact_person VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
batch_no VARCHAR(100) PRIMARY KEY,
|
||||
tenant_id VARCHAR(50),
|
||||
product_id VARCHAR(50) REFERENCES products(id),
|
||||
supplier_id VARCHAR(50) REFERENCES suppliers(id),
|
||||
expiry DATE NOT NULL,
|
||||
qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0),
|
||||
purchase_price DECIMAL(10, 2),
|
||||
@@ -96,6 +125,7 @@ async function initDB() {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS order_drafts (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
tenant_id VARCHAR(50),
|
||||
subject_ref VARCHAR(100),
|
||||
prescription_ref VARCHAR(50),
|
||||
items JSONB,
|
||||
@@ -116,10 +146,26 @@ async function initDB() {
|
||||
);
|
||||
`);
|
||||
|
||||
// 4. Phase 2: Prescriptions
|
||||
// 4. Phase 2: Prescriptions and Patients
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS patients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id VARCHAR(50),
|
||||
hospital_patient_id VARCHAR(100) UNIQUE NOT NULL,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
contact_number VARCHAR(50),
|
||||
email VARCHAR(255),
|
||||
allergies TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS prescriptions (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
tenant_id VARCHAR(50),
|
||||
image_path TEXT NOT NULL,
|
||||
image_hash VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'processing',
|
||||
|
||||
@@ -11,6 +11,7 @@ import dispensingRouter from './modules/dispensing/router';
|
||||
import auditRouter from './modules/audit/router';
|
||||
import prescriptionRouter from './modules/prescription/router';
|
||||
import aiRouter from './modules/ai/router';
|
||||
import integrationRouter from './modules/integration/router';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8080;
|
||||
@@ -45,6 +46,7 @@ app.use('/v1/orders', dispensingRouter);
|
||||
app.use('/v1/audit', auditRouter);
|
||||
app.use('/v1/rx', prescriptionRouter);
|
||||
app.use('/v1/ai', aiRouter);
|
||||
app.use('/v1/integration', integrationRouter);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
+16
-11
@@ -26,25 +26,30 @@ export const requireAuth: RequestHandler = (req: Request, res: Response, next: N
|
||||
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
|
||||
if (payload.userId) {
|
||||
// User JWT Token
|
||||
(req as any).user = payload;
|
||||
req.actorId = payload.userId;
|
||||
} else {
|
||||
// M2M Tenant JWT Token
|
||||
if (!actorId) {
|
||||
res.status(403).json({
|
||||
error: 'FORBIDDEN',
|
||||
message: 'Missing X-Actor-Id header for M2M token.',
|
||||
details: {},
|
||||
retryable: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
req.tenantId = payload.tenantId;
|
||||
req.actorId = actorId;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { AIService } from './service';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
const aiService = new AIService();
|
||||
|
||||
router.post('/voice', async (req, res, next) => {
|
||||
router.post('/voice', requireAuth, 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);
|
||||
const actorId = req.actorId as string;
|
||||
const result = await aiService.processVoiceCommand(transcript, actorId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
+132
-100
@@ -1,10 +1,10 @@
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import OpenAI from 'openai';
|
||||
import { pool } from '../../db/pool';
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
export class AIService {
|
||||
async processVoiceCommand(transcript: string) {
|
||||
async processVoiceCommand(transcript: string, actorId: 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.
|
||||
@@ -12,120 +12,145 @@ 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 about patients (e.g. how many patients), use get_total_patients.
|
||||
If the user asks about prescriptions for today, use get_todays_prescriptions.
|
||||
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 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}"`;
|
||||
If the user asks to add a new supplier, use add_supplier.
|
||||
If the user asks to add a new patient, use add_patient.`;
|
||||
|
||||
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.",
|
||||
},
|
||||
{
|
||||
const aiTools: any = [
|
||||
{ type: "function", function: { name: "get_low_stock", description: "Get the count of medicines that are running low on stock." } },
|
||||
{ type: "function", function: { name: "get_total_medicines", description: "Get the total number of unique medicines in the system." } },
|
||||
{ type: "function", function: { name: "get_today_sales", description: "Get the total revenue or sales for today." } },
|
||||
{ type: "function", function: { name: "get_expiring_medicines", description: "Get the count of medicines expiring within the next 30 days." } },
|
||||
{ type: "function", function: { name: "get_total_patients", description: "Get the total number of registered patients." } },
|
||||
{ type: "function", function: { name: "get_todays_prescriptions", description: "Get the number of prescriptions dispensed today." } },
|
||||
{ type: "function", function: { name: "get_total_suppliers", description: "Get the total number of wholesale distributors/suppliers." } },
|
||||
{ type: "function", function: {
|
||||
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"]
|
||||
}
|
||||
},
|
||||
{
|
||||
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
|
||||
}},
|
||||
{ type: "function", function: {
|
||||
name: "search_supplier",
|
||||
description: "Search for a wholesale supplier 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.",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
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)." }
|
||||
brand_name: { type: "string" },
|
||||
mrp: { type: "number" },
|
||||
pack_size: { type: "number", description: "Number of units per pack" },
|
||||
form: { type: "string" }
|
||||
},
|
||||
required: ["brand_name", "mrp"]
|
||||
}
|
||||
},
|
||||
{
|
||||
}},
|
||||
{ type: "function", function: {
|
||||
name: "add_stock",
|
||||
description: "Add stock (a new batch) for an existing medicine.",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
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." }
|
||||
product_name: { type: "string" },
|
||||
qty: { type: "number" }
|
||||
},
|
||||
required: ["product_name", "qty"]
|
||||
}
|
||||
}},
|
||||
{ type: "function", function: {
|
||||
name: "add_supplier",
|
||||
description: "Register a new wholesale distributor/supplier.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
phone: { type: "string" }
|
||||
},
|
||||
{
|
||||
required: ["name"]
|
||||
}
|
||||
}},
|
||||
{ type: "function", function: {
|
||||
name: "add_patient",
|
||||
description: "Register a new patient.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
phone: { type: "string" }
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
}},
|
||||
{ type: "function", function: {
|
||||
name: "navigate_to",
|
||||
description: "Navigate the user to a specific page.",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
type: "object",
|
||||
properties: {
|
||||
target: {
|
||||
type: "STRING",
|
||||
description: "The page to navigate to. Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions'"
|
||||
type: "string",
|
||||
description: "Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions', 'patients', 'suppliers', 'registers', 'stock-movements', 'history'"
|
||||
}
|
||||
},
|
||||
required: ["target"]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}}
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3.6-flash',
|
||||
contents: [prompt],
|
||||
config: {
|
||||
tools: [aiTools as any],
|
||||
}
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: transcript }
|
||||
],
|
||||
tools: aiTools,
|
||||
tool_choice: "auto"
|
||||
});
|
||||
|
||||
const functionCalls = response.functionCalls;
|
||||
const message = response.choices[0].message;
|
||||
|
||||
if (functionCalls && functionCalls.length > 0) {
|
||||
const call = functionCalls[0];
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
const call = (message.tool_calls[0] as any).function;
|
||||
let toolData = null;
|
||||
let actionCommand = 'NONE';
|
||||
const args = call.arguments ? JSON.parse(call.arguments) : {};
|
||||
|
||||
if (call.name === 'get_low_stock') {
|
||||
const { rows } = await pool.query('SELECT count(*) as count FROM batches WHERE qty_on_hand <= 50');
|
||||
const { rows } = await pool.query('SELECT count(*) as count FROM batches WHERE qty_on_hand <= 50 AND qty_on_hand > 0 AND tenant_id = $1', [actorId]);
|
||||
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`);
|
||||
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'`);
|
||||
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]);
|
||||
toolData = { batches_expiring_in_30_days: rows[0].count };
|
||||
actionCommand = 'VIEW_EXPIRING_STOCK';
|
||||
} else if (call.name === 'get_total_patients') {
|
||||
const { rows } = await pool.query('SELECT count(*) as count FROM patients');
|
||||
toolData = { total_patients: rows[0].count };
|
||||
} else if (call.name === 'get_total_suppliers') {
|
||||
const { rows } = await pool.query('SELECT count(*) as count FROM suppliers');
|
||||
toolData = { total_suppliers: rows[0].count };
|
||||
} else if (call.name === 'get_todays_prescriptions') {
|
||||
const { rows } = await pool.query('SELECT count(*) as count FROM prescriptions WHERE DATE(created_at) = CURRENT_DATE');
|
||||
toolData = { todays_prescriptions: rows[0].count };
|
||||
} else if (call.name === 'search_medicine') {
|
||||
const query = (call.args as any)?.query || '';
|
||||
const query = args.query || '';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.brand_name, COALESCE(SUM(b.qty_on_hand), 0) as total_stock
|
||||
FROM products p
|
||||
@@ -134,73 +159,80 @@ User transcript: "${transcript}"`;
|
||||
GROUP BY p.brand_name`,
|
||||
[`%${query}%`]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
toolData = { search_results: rows };
|
||||
} else {
|
||||
toolData = { message: `No medicine found matching '${query}'.` };
|
||||
}
|
||||
toolData = rows.length > 0 ? { search_results: rows } : { message: `No medicine found matching '${query}'.` };
|
||||
actionCommand = 'NAVIGATE_INVENTORY';
|
||||
} else if (call.name === 'search_supplier') {
|
||||
const query = args.query || '';
|
||||
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 === '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]
|
||||
);
|
||||
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'; // Assume there's a catalogue or inventory view
|
||||
actionCommand = 'NAVIGATE_CATALOGUE';
|
||||
} 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]
|
||||
);
|
||||
await pool.query('INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) VALUES ($1, $2, $3, $4, $5)', [batchNo, pRes.rows[0].id, 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.` };
|
||||
toolData = { success: false, message: `Could not find product ${args.product_name} in database.` };
|
||||
}
|
||||
} else if (call.name === 'add_supplier') {
|
||||
const id = `sup_${Date.now()}`;
|
||||
await pool.query('INSERT INTO suppliers (id, name, contact_number) VALUES ($1, $2, $3)', [id, args.name, args.phone || '']);
|
||||
toolData = { success: true, message: `Added supplier ${args.name} successfully.` };
|
||||
actionCommand = 'NAVIGATE_SUPPLIERS';
|
||||
} else if (call.name === 'add_patient') {
|
||||
const id = `pat_${Date.now()}`;
|
||||
await pool.query('INSERT INTO patients (id, name, phone) VALUES ($1, $2, $3)', [id, args.name, args.phone || '']);
|
||||
toolData = { success: true, message: `Added patient ${args.name} successfully.` };
|
||||
actionCommand = 'NAVIGATE_PATIENTS';
|
||||
} else if (call.name === 'navigate_to') {
|
||||
const target = (call.args as any)?.target;
|
||||
const target = args.target?.toLowerCase();
|
||||
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 === 'inventory' || target === 'catalogue') actionCommand = 'NAVIGATE_INVENTORY';
|
||||
if (target === 'prescriptions') actionCommand = 'NAVIGATE_PRESCRIPTIONS';
|
||||
if (target === 'patients') actionCommand = 'NAVIGATE_PATIENTS';
|
||||
if (target === 'suppliers') actionCommand = 'NAVIGATE_SUPPLIERS';
|
||||
if (target === 'registers') actionCommand = 'NAVIGATE_REGISTERS';
|
||||
if (target === 'stock-movements') actionCommand = 'NAVIGATE_STOCK_MOVEMENTS';
|
||||
if (target === 'history') actionCommand = 'NAVIGATE_HISTORY';
|
||||
}
|
||||
|
||||
// Step 2: Ask Gemini to generate the final response in the user's language
|
||||
// Step 2: Ask GPT-4o 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)}
|
||||
Database result for user query: ${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).
|
||||
1. Detect the language of the User's original 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.`;
|
||||
4. NEVER reply in English unless the user spoke in English. Keep it conversational and concise.
|
||||
5. Do not use markdown styling.`;
|
||||
|
||||
const finalResponse = await ai.models.generateContent({
|
||||
model: 'gemini-3.6-flash',
|
||||
contents: [finalPrompt]
|
||||
const finalResponse = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: finalPrompt },
|
||||
{ role: 'user', content: transcript }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
replyText: finalResponse.text || "Action completed.",
|
||||
replyText: finalResponse.choices[0].message.content || "Action completed.",
|
||||
actionCommand
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
replyText: response.text || "I didn't quite catch that.",
|
||||
replyText: message.content || "I didn't quite catch that.",
|
||||
actionCommand: 'NONE'
|
||||
};
|
||||
} catch (e) {
|
||||
|
||||
+57
-11
@@ -33,11 +33,13 @@ 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 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]);
|
||||
SELECT sm.id, sm.batch_no, sm.movement_type, sm.qty_delta, sm.reference_id, sm.created_at
|
||||
FROM stock_movements sm
|
||||
JOIN batches b ON sm.batch_no = b.batch_no
|
||||
WHERE b.tenant_id = $1
|
||||
ORDER BY sm.created_at DESC
|
||||
LIMIT $2
|
||||
`, [req.actorId, limit ? parseInt(limit as string) : 50]);
|
||||
|
||||
res.status(200).json({
|
||||
ai_calls: rows,
|
||||
@@ -59,9 +61,10 @@ router.get('/overrides', requireAuth, async (req, res, next) => {
|
||||
actor_user_id,
|
||||
occurred_at
|
||||
FROM overrides_log
|
||||
WHERE actor_user_id = $1
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
`, [req.actorId]);
|
||||
|
||||
// Grouping for summary
|
||||
const by_actor: Record<string, number> = {};
|
||||
@@ -89,10 +92,12 @@ 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
|
||||
`);
|
||||
h.id, h.dispensing_id, h.patient_name, h.prescriber_name, h.drug_name, h.batch_no, h.qty, h.created_at
|
||||
FROM h1_registers h
|
||||
JOIN dispensing_records d ON h.dispensing_id = d.id
|
||||
WHERE d.tenant_id = $1
|
||||
ORDER BY h.created_at DESC
|
||||
`, [req.actorId]);
|
||||
res.status(200).json({ register: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -111,12 +116,53 @@ router.get('/sales-register', requireAuth, async (req, res, next) => {
|
||||
d.total_amount,
|
||||
d.payment_method
|
||||
FROM dispensing_records d
|
||||
WHERE d.tenant_id = $1
|
||||
ORDER BY d.created_at DESC
|
||||
`);
|
||||
`, [req.actorId]);
|
||||
res.status(200).json({ register: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /v1/audit/dashboard-metrics
|
||||
router.get('/dashboard-metrics', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const actorId = req.actorId;
|
||||
const { rows: patients } = await pool.query('SELECT COUNT(DISTINCT subject_ref) FROM dispensing_records WHERE tenant_id = $1', [actorId]);
|
||||
const { rows: prescriptions } = await pool.query('SELECT COUNT(*) FROM dispensing_records WHERE tenant_id = $1', [actorId]);
|
||||
const { rows: inventory } = await pool.query('SELECT COUNT(DISTINCT product_id) FROM batches WHERE tenant_id = $1 AND qty_on_hand > 0', [actorId]);
|
||||
const { rows: dispensedToday } = await pool.query(`
|
||||
SELECT COUNT(*) FROM dispensing_records
|
||||
WHERE DATE(created_at) = CURRENT_DATE AND tenant_id = $1
|
||||
`, [actorId]);
|
||||
|
||||
const { rows: trendData } = await pool.query(`
|
||||
SELECT
|
||||
TO_CHAR(DATE(created_at), 'Mon DD') as date,
|
||||
SUM(CASE WHEN movement_type = 'receipt' THEN qty_delta ELSE 0 END) as received,
|
||||
SUM(CASE WHEN movement_type = 'dispense' THEN ABS(qty_delta) ELSE 0 END) as dispensed
|
||||
FROM stock_movements sm
|
||||
JOIN batches b ON sm.batch_no = b.batch_no
|
||||
WHERE sm.created_at >= CURRENT_DATE - INTERVAL '6 days' AND b.tenant_id = $1
|
||||
GROUP BY DATE(sm.created_at)
|
||||
ORDER BY DATE(sm.created_at) ASC
|
||||
`, [actorId]);
|
||||
|
||||
res.status(200).json({
|
||||
totalPatients: parseInt(patients[0].count) || 0,
|
||||
totalPrescriptions: parseInt(prescriptions[0].count) || 0,
|
||||
inventoryItems: parseInt(inventory[0].count) || 0,
|
||||
dispensedToday: parseInt(dispensedToday[0].count) || 0,
|
||||
trend: trendData.map(t => ({
|
||||
date: t.date,
|
||||
received: parseInt(t.received) || 0,
|
||||
dispensed: parseInt(t.dispensed) || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -9,7 +9,8 @@ const service = new CatalogueService();
|
||||
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);
|
||||
const actorId = req.actorId as string;
|
||||
const drugs = await service.getAllDrugs(limit, actorId);
|
||||
res.status(200).json({ drugs });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -19,7 +20,8 @@ router.get('/drugs', requireAuth, async (req, res, next) => {
|
||||
// POST /v1/drugs
|
||||
router.post('/drugs', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await service.addMedicine(req.body);
|
||||
const actorId = req.actorId as string;
|
||||
const result = await service.addMedicine(req.body, actorId);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -32,6 +34,7 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => {
|
||||
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;
|
||||
const actorId = req.actorId as string;
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
res.status(400).json({
|
||||
@@ -41,7 +44,7 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = await service.searchDrugs(q, form, limit);
|
||||
const candidates = await service.searchDrugs(q, form, limit, actorId);
|
||||
res.status(200).json({ candidates });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -52,7 +55,8 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => {
|
||||
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);
|
||||
const actorId = req.actorId as string;
|
||||
const batches = await service.getProductBatches(productId, actorId);
|
||||
res.status(200).json({ batches });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pool } from '../../db/pool';
|
||||
|
||||
export class CatalogueService {
|
||||
async searchDrugs(q: string, form?: string, limit: number = 10) {
|
||||
async searchDrugs(q: string, form: string | undefined, limit: number = 10, actorId: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
p.id as product_id,
|
||||
@@ -15,19 +15,20 @@ export class CatalogueService {
|
||||
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
|
||||
(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
|
||||
JOIN drugs d ON p.drug_id = d.id
|
||||
WHERE p.brand_name ILIKE $1 OR d.generic_name ILIKE $1
|
||||
WHERE (p.brand_name ILIKE $1 OR d.generic_name ILIKE $1)
|
||||
AND (p.tenant_id = $3 OR p.tenant_id IS NULL)
|
||||
LIMIT $2
|
||||
`;
|
||||
const values = [`%${q}%`, limit];
|
||||
const values = [`%${q}%`, limit, actorId];
|
||||
|
||||
const { rows } = await pool.query(query, values);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getAllDrugs(limit: number = 50) {
|
||||
async getAllDrugs(limit: number = 50, actorId: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
p.id as product_id,
|
||||
@@ -41,17 +42,19 @@ export class CatalogueService {
|
||||
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
|
||||
(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
|
||||
FROM products p
|
||||
JOIN drugs d ON p.drug_id = d.id
|
||||
WHERE p.tenant_id = $2 OR EXISTS (SELECT 1 FROM batches WHERE product_id = p.id AND tenant_id = $2)
|
||||
ORDER BY p.brand_name ASC
|
||||
LIMIT $1
|
||||
`;
|
||||
const { rows } = await pool.query(query, [limit]);
|
||||
const { rows } = await pool.query(query, [limit, actorId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getProductBatches(productId: string) {
|
||||
async getProductBatches(productId: string, actorId: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
batch_no,
|
||||
@@ -64,35 +67,35 @@ export class CatalogueService {
|
||||
ELSE 'available'
|
||||
END as status
|
||||
FROM batches
|
||||
WHERE product_id = $1 AND qty_on_hand > 0
|
||||
WHERE product_id = $1 AND qty_on_hand > 0 AND tenant_id = $2
|
||||
ORDER BY expiry ASC
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(query, [productId]);
|
||||
const { rows } = await pool.query(query, [productId, actorId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async addMedicine(payload: any) {
|
||||
const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp } = payload;
|
||||
async addMedicine(payload: any, actorId: string) {
|
||||
const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp, pack_size } = payload;
|
||||
|
||||
// Check if generic exists
|
||||
let drugId;
|
||||
const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1`, [generic_name]);
|
||||
const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1 AND (tenant_id = $2 OR tenant_id IS NULL)`, [generic_name, actorId]);
|
||||
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']
|
||||
`INSERT INTO drugs (id, generic_name, schedule, tenant_id) VALUES ($1, $2, $3, $4)`,
|
||||
[drugId, generic_name, schedule || 'H', actorId]
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
INSERT INTO products (id, drug_id, brand_name, manufacturer, strength, form, mrp, pack_size, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, [productId, drugId, brand_name, manufacturer, strength, form, mrp, pack_size || 1, actorId]);
|
||||
|
||||
return { product_id: productId, drug_id: drugId };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const service = new DispensingService();
|
||||
// GET /v1/orders/history
|
||||
router.get('/history', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const history = await service.getDispensingHistory();
|
||||
const history = await service.getDispensingHistory(req.actorId as string);
|
||||
res.status(200).json({ history });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -21,7 +21,7 @@ router.get('/history', requireAuth, async (req, res, next) => {
|
||||
// GET /v1/orders/h1-register
|
||||
router.get('/h1-register', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const records = await service.getH1Register();
|
||||
const records = await service.getH1Register(req.actorId as string);
|
||||
res.status(200).json({ records });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -31,7 +31,7 @@ router.get('/h1-register', requireAuth, async (req, res, next) => {
|
||||
// POST /v1/orders
|
||||
router.post('/', requireAuth, requireHeaders, async (req, res, next) => {
|
||||
try {
|
||||
const draft = await service.createOrderDraft(req.body);
|
||||
const draft = await service.createOrderDraft(req.body, req.actorId as string);
|
||||
res.status(201).json(draft);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -59,7 +59,7 @@ router.post('/:id/precheck', requireAuth, async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'CONTEXT_INCOMPLETE' });
|
||||
}
|
||||
|
||||
const precheckResult = await service.runGates(clinical_context, items);
|
||||
const precheckResult = await service.runGates(clinical_context, items, req.actorId as string);
|
||||
res.status(200).json(precheckResult);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -84,7 +84,7 @@ router.post('/:id/dispense', requireAuth, async (req, res, next) => {
|
||||
|
||||
// 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]);
|
||||
const draftRes = await pool.query('SELECT items FROM order_drafts WHERE id = $1 AND tenant_id = $2', [prescriptionId, req.actorId]);
|
||||
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;
|
||||
}
|
||||
@@ -95,7 +95,7 @@ router.post('/:id/dispense', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
|
||||
// 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 || []);
|
||||
const result = await service.executeDispense(subject_ref, prescriber_name || 'Unknown Prescriber', items, actorId as string, actorId as string, prescriptionId as string | undefined, payment_method as string, overrides || []);
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -2,14 +2,14 @@ import { pool } from '../../db/pool';
|
||||
import { webhookService } from '../webhooks/service';
|
||||
|
||||
export class DispensingService {
|
||||
async createOrderDraft(payload: any) {
|
||||
async createOrderDraft(payload: any, tenantId: string) {
|
||||
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]);
|
||||
INSERT INTO order_drafts (id, tenant_id, subject_ref, prescription_ref, items)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [draftId, tenantId, payload.subject_ref, payload.prescription_ref || null, items]);
|
||||
|
||||
return { order_draft_id: draftId, status: 'draft' };
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class DispensingService {
|
||||
return { order_draft_id: draftId, updated_items: items.length };
|
||||
}
|
||||
|
||||
async runGates(clinicalContext: any, items: { product_id: string, qty: number }[]) {
|
||||
async runGates(clinicalContext: any, items: { product_id: string, qty: number }[], tenantId: string) {
|
||||
// Phase 1: Basic hard/soft gate mock implementations based on clinical context
|
||||
const blocks: any[] = [];
|
||||
const warnings: any[] = [];
|
||||
@@ -47,10 +47,11 @@ export class DispensingService {
|
||||
|
||||
// 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]);
|
||||
const prodRes = await pool.query(`SELECT mrp, dpco_ceiling, pack_size 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 packSize = parseInt(prod.pack_size, 10) || 1;
|
||||
const dpco = parseFloat(prod.dpco_ceiling);
|
||||
if (!isNaN(dpco) && mrp > dpco) {
|
||||
blocks.push({
|
||||
@@ -61,14 +62,14 @@ export class DispensingService {
|
||||
});
|
||||
tier = 'elevated';
|
||||
}
|
||||
totalAmount += mrp * item.qty;
|
||||
totalAmount += (mrp / packSize) * item.qty;
|
||||
}
|
||||
const { rows } = await pool.query(`
|
||||
SELECT batch_no, expiry
|
||||
FROM batches
|
||||
WHERE product_id = $1 AND qty_on_hand > 0
|
||||
WHERE product_id = $1 AND tenant_id = $2 AND qty_on_hand > 0
|
||||
ORDER BY expiry ASC LIMIT 1
|
||||
`, [item.product_id]);
|
||||
`, [item.product_id, tenantId]);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const batch = rows[0];
|
||||
@@ -104,7 +105,7 @@ export class DispensingService {
|
||||
};
|
||||
}
|
||||
|
||||
async executeDispense(subjectRef: string, prescriberName: string, items: any[], actorId: string, tenantId?: string, prescriptionId?: string, paymentMethod: string = 'cash', overrides: any[] = []) {
|
||||
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 = '';
|
||||
@@ -124,7 +125,7 @@ export class DispensingService {
|
||||
|
||||
// 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
|
||||
SELECT p.brand_name, p.mrp, p.pack_size, p.dpco_ceiling, d.schedule, d.generic_name
|
||||
FROM products p
|
||||
JOIN drugs d ON p.drug_id = d.id
|
||||
WHERE p.id = $1
|
||||
@@ -136,6 +137,7 @@ export class DispensingService {
|
||||
|
||||
const prod = prodRes.rows[0];
|
||||
const mrp = parseFloat(prod.mrp) || 0;
|
||||
const packSize = parseInt(prod.pack_size, 10) || 1;
|
||||
const dpco = parseFloat(prod.dpco_ceiling);
|
||||
if (!isNaN(dpco) && mrp > dpco) {
|
||||
throw {
|
||||
@@ -144,16 +146,16 @@ export class DispensingService {
|
||||
message: `Billed price for ${prod.brand_name} exceeds DPCO ceiling of ${dpco}.`
|
||||
};
|
||||
}
|
||||
totalAmount += mrp * item.qty;
|
||||
totalAmount += (mrp / packSize) * 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
|
||||
WHERE product_id = $1 AND tenant_id = $2 AND qty_on_hand > 0 AND expiry > CURRENT_DATE
|
||||
ORDER BY expiry ASC
|
||||
FOR UPDATE
|
||||
`, [item.product_id]);
|
||||
`, [item.product_id, tenantId]);
|
||||
|
||||
let allocatedTotal = 0;
|
||||
|
||||
@@ -253,11 +255,11 @@ export class DispensingService {
|
||||
}
|
||||
}
|
||||
|
||||
async getDispensingHistory() {
|
||||
async getDispensingHistory(tenantId: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
d.id as dispensing_id,
|
||||
d.subject_ref as patient_name,
|
||||
COALESCE(pat.full_name, d.subject_ref) as patient_name,
|
||||
d.dispensed_by,
|
||||
d.created_at,
|
||||
d.prescription_id,
|
||||
@@ -272,22 +274,36 @@ export class DispensingService {
|
||||
)
|
||||
) 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
|
||||
GROUP BY d.id
|
||||
WHERE d.tenant_id = $1
|
||||
GROUP BY d.id, pat.full_name
|
||||
ORDER BY d.created_at DESC
|
||||
`;
|
||||
const { rows } = await pool.query(query);
|
||||
const { rows } = await pool.query(query, [tenantId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getH1Register() {
|
||||
async getH1Register(tenantId: string) {
|
||||
const query = `
|
||||
SELECT * FROM h1_registers
|
||||
ORDER BY created_at DESC
|
||||
SELECT
|
||||
h.id,
|
||||
h.dispensing_id,
|
||||
COALESCE(pat.full_name, h.patient_name) as patient_name,
|
||||
h.prescriber_name,
|
||||
h.drug_name,
|
||||
h.batch_no,
|
||||
h.qty,
|
||||
h.created_at
|
||||
FROM h1_registers h
|
||||
JOIN dispensing_records d ON h.dispensing_id = d.id
|
||||
LEFT JOIN patients pat ON pat.hospital_patient_id = h.patient_name OR pat.id::text = h.patient_name
|
||||
WHERE d.tenant_id = $1
|
||||
ORDER BY h.created_at DESC
|
||||
`;
|
||||
const { rows } = await pool.query(query);
|
||||
const { rows } = await pool.query(query, [tenantId]);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
import { Router } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { IdentityService } from './service';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
const identityService = new IdentityService();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
|
||||
|
||||
// GET /v1/auth/me (Get Profile)
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const userId = req.actorId as string;
|
||||
const profile = await identityService.getUserProfile(userId);
|
||||
res.status(200).json({ success: true, profile });
|
||||
} catch (error: any) {
|
||||
res.status(404).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /v1/auth/me (Update Profile)
|
||||
router.put('/me', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const userId = req.actorId as string;
|
||||
const profile = await identityService.updateUserProfile(userId, req.body);
|
||||
res.status(200).json({ success: true, profile });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /v1/auth/register (Create a new pharmacist)
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { hospital_id, username, password, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
|
||||
if (!passwordRegex.test(password)) {
|
||||
return res.status(400).json({
|
||||
error: 'Password must be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number, and one special character.'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await identityService.registerUser({
|
||||
hospital_id,
|
||||
username,
|
||||
passwordPlain: password,
|
||||
pharmacy_name,
|
||||
dl_number,
|
||||
gstin,
|
||||
pharmacist_reg_number,
|
||||
address
|
||||
});
|
||||
res.status(201).json({ success: true, user });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /v1/auth/login (Pharmacist Login)
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const data = await identityService.login(username, password);
|
||||
res.status(200).json({ success: true, ...data });
|
||||
} catch (error: any) {
|
||||
res.status(401).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /v1/auth/logout (Pharmacist Logout)
|
||||
router.post('/logout', (req, res) => {
|
||||
// For stateless JWT, true logout happens on the client by deleting the token.
|
||||
// We provide this endpoint for consistency and future expansion (e.g., token blacklisting).
|
||||
res.status(200).json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
// POST /v1/auth/token
|
||||
router.post('/token', (req, res) => {
|
||||
const { grant_type, client_id, client_secret } = req.body;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { pool } from '../../db/pool';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
|
||||
|
||||
export class IdentityService {
|
||||
async registerUser(payload: {
|
||||
hospital_id?: string | number;
|
||||
username: string;
|
||||
passwordPlain: string;
|
||||
pharmacy_name?: string;
|
||||
dl_number?: string;
|
||||
gstin?: string;
|
||||
pharmacist_reg_number?: string;
|
||||
address?: string;
|
||||
}) {
|
||||
const { hospital_id, username, passwordPlain, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address } = payload;
|
||||
|
||||
// 1. Check if user already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
|
||||
if (existing.rows.length > 0) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
// 2. Hash password
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(passwordPlain, saltRounds);
|
||||
|
||||
const id = "usr_" + Date.now();
|
||||
|
||||
const tenantId = hospital_id ? String(hospital_id) : 'act_admin';
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO users (id, username, password_hash, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[id, username, passwordHash, 'pharmacist', pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, tenantId]
|
||||
);
|
||||
|
||||
return { id, username, role: 'pharmacist', tenant_id: tenantId };
|
||||
}
|
||||
|
||||
async login(username: string, passwordPlain: string) {
|
||||
// 1. Find user
|
||||
const result = await pool.query('SELECT * FROM users WHERE username = $1', [username]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// 2. Verify password
|
||||
const isValid = await bcrypt.compare(passwordPlain, user.password_hash);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// 3. Issue JWT Token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '12h' } // Usually 8-12 hours for a shift
|
||||
);
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tenant_id: user.tenant_id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
[userId]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async updateUserProfile(userId: string, data: {
|
||||
pharmacy_name?: string;
|
||||
dl_number?: string;
|
||||
gstin?: string;
|
||||
pharmacist_reg_number?: string;
|
||||
address?: string;
|
||||
}) {
|
||||
// Only update allowed fields
|
||||
await pool.query(
|
||||
`UPDATE users
|
||||
SET pharmacy_name = COALESCE($1, pharmacy_name),
|
||||
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]
|
||||
);
|
||||
|
||||
return this.getUserProfile(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IntegrationService } from './service';
|
||||
|
||||
export class IntegrationController {
|
||||
|
||||
// Endpoint to handle patient sync webhooks from Hospital API
|
||||
static async syncHospitalPatient(req: Request, res: Response) {
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
// Basic validation
|
||||
if (!payload.hospital_patient_id || !payload.full_name) {
|
||||
return res.status(400).json({ error: 'Missing required patient fields' });
|
||||
}
|
||||
|
||||
// Save or update the patient in Pharmacy DB
|
||||
const result = await IntegrationService.saveOrUpdatePatient(payload);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Patient synced successfully',
|
||||
data: result
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[IntegrationController] Error syncing patient:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint to fetch all patients synced to Pharmacy DB
|
||||
static async getAllPatients(req: Request, res: Response) {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] || 'act_admin';
|
||||
const patients = await IntegrationService.getAllPatients(String(tenantId));
|
||||
res.status(200).json({
|
||||
message: 'Patients fetched successfully',
|
||||
data: patients
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[IntegrationController] Error fetching patients:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Telegaruda Endpoints ---
|
||||
static async receivePrescription(req: Request, res: Response) {
|
||||
try {
|
||||
const payload = req.body;
|
||||
|
||||
const patientId = payload.patient_id || payload.patientId;
|
||||
const medicines = payload.values || payload.medicines;
|
||||
|
||||
if (!patientId || !medicines) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const id = "rx_" + Date.now() + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Map Hospital API payload to Pharmacy UI expected format
|
||||
const formattedItems = medicines.map((med: any) => {
|
||||
const m = med.medicine_taken?.m || '0';
|
||||
const a = med.medicine_taken?.a || '0';
|
||||
const e = med.medicine_taken?.e || '0';
|
||||
const n = med.medicine_taken?.n || '0';
|
||||
return {
|
||||
parsed: {
|
||||
qty: parseInt(med.total_qty) || 0,
|
||||
dose: `${m}-${a}-${e}-${n}`,
|
||||
form: med.medicine_type?.toLowerCase() || 'tablet',
|
||||
brand: med.medicine_name,
|
||||
total_qty_str: med.total_qty,
|
||||
duration: med.total_medicine_duration || med.medicine_duration,
|
||||
instruction: med.medicine_instruction
|
||||
},
|
||||
verbatim: `${med.medicine_name} - ${med.medicine_instruction}`,
|
||||
confidence: 1.0,
|
||||
confusable_with: [],
|
||||
explicitly_confirmed: true
|
||||
};
|
||||
});
|
||||
|
||||
let patientName = payload.patientName || payload.patient_name;
|
||||
if (!patientName && patientId) {
|
||||
try {
|
||||
const client = await require('../../db/pool').pool.connect();
|
||||
const pResult = await client.query('SELECT full_name FROM patients WHERE hospital_patient_id = $1', [String(patientId)]);
|
||||
if (pResult.rows.length > 0) {
|
||||
patientName = pResult.rows[0].full_name;
|
||||
}
|
||||
client.release();
|
||||
} catch(e) { console.log('Error fetching patient name:', e); }
|
||||
}
|
||||
|
||||
const structuredData = {
|
||||
items: formattedItems,
|
||||
patient: {
|
||||
age: 30, // Dummy age, update if you add age to payload later
|
||||
name: patientName || ""
|
||||
},
|
||||
prescriber: {
|
||||
name: payload.providerName || payload.doctorName || payload.doctor_name || "",
|
||||
registration_number: "N/A"
|
||||
},
|
||||
image_quality: "digital_webhook"
|
||||
};
|
||||
|
||||
const query = `
|
||||
INSERT INTO prescriptions (id, image_path, image_hash, status, extracted_data, confirmed_data, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`;
|
||||
const values = [
|
||||
id,
|
||||
'E-Prescription (Digital Webhook)',
|
||||
'verified-webhook-hash',
|
||||
'pending_review',
|
||||
JSON.stringify(structuredData),
|
||||
null, // Leave confirmed_data null so pharmacist can review it
|
||||
payload.hospital_id ? String(payload.hospital_id) : 'act_admin'
|
||||
];
|
||||
|
||||
await require('../../db/pool').pool.query(query, values);
|
||||
|
||||
res.status(201).json({ message: 'Prescription received and saved', data: payload, id });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchPatientDetails(req: Request, res: Response) {
|
||||
try {
|
||||
const patientId = String(req.params.patientId);
|
||||
res.status(200).json({ message: 'Mock details', data: { patientId } });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllPrescriptions(req: Request, res: Response) {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] || 'act_admin';
|
||||
const client = await require('../../db/pool').pool.connect();
|
||||
try {
|
||||
const query = `SELECT * FROM prescriptions WHERE tenant_id = $1 ORDER BY created_at DESC;`;
|
||||
const result = await client.query(query, [String(tenantId)]);
|
||||
res.status(200).json({ message: 'All prescriptions', data: result.rows });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express';
|
||||
import { IntegrationController } from './controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Middleware to protect integration routes using custom headers
|
||||
router.use((req, res, next) => {
|
||||
// Allow GET endpoints for browser testing if you want, but strictly protect POSTs.
|
||||
if (req.method === 'GET') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const client = req.headers['x-api-client'];
|
||||
const secret = req.headers['x-api-secret'];
|
||||
|
||||
// JWT_SECRET is the key we generated earlier in .env
|
||||
const EXPECTED_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
if (!client || !secret) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Missing API credentials' });
|
||||
}
|
||||
|
||||
if (client !== 'hospital-api' && client !== 'telegaruda') {
|
||||
return res.status(401).json({ error: 'Unauthorized: Invalid client ID' });
|
||||
}
|
||||
|
||||
if (secret !== EXPECTED_SECRET) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Invalid client secret' });
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Endpoint for Hospital API to sync patient data (Webhook receiver)
|
||||
router.post('/hospital/patient-sync', IntegrationController.syncHospitalPatient);
|
||||
|
||||
// Endpoint to get all synced patients in Pharmacy
|
||||
router.get('/hospital/patients', IntegrationController.getAllPatients);
|
||||
|
||||
// Endpoint for Telegaruda to push prescriptions
|
||||
router.post('/telegaruda/prescription', IntegrationController.receivePrescription);
|
||||
|
||||
// Endpoint for Pharmacy to fetch patient details from Telegaruda
|
||||
router.get('/telegaruda/patient/:patientId', IntegrationController.fetchPatientDetails);
|
||||
|
||||
// Endpoint to view all synced prescriptions (For testing)
|
||||
router.get('/hospital/prescriptions', IntegrationController.getAllPrescriptions);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { pool } from '../../db/pool';
|
||||
|
||||
export class IntegrationService {
|
||||
|
||||
/**
|
||||
* UPSERT a patient into the Pharmacy DB coming from the Hospital API webhook.
|
||||
*/
|
||||
static async saveOrUpdatePatient(payload: any) {
|
||||
console.log('[IntegrationService] Syncing patient from Hospital API:', payload.hospital_patient_id);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const allergies = payload.allergies ? JSON.stringify(payload.allergies) : null;
|
||||
|
||||
const query = `
|
||||
INSERT INTO patients (hospital_patient_id, full_name, contact_number, email, allergies, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (hospital_patient_id)
|
||||
DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name,
|
||||
contact_number = EXCLUDED.contact_number,
|
||||
email = EXCLUDED.email,
|
||||
allergies = EXCLUDED.allergies,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id, hospital_patient_id, full_name;
|
||||
`;
|
||||
|
||||
const values = [
|
||||
payload.hospital_patient_id,
|
||||
payload.full_name,
|
||||
payload.contact_number || null,
|
||||
payload.email || null,
|
||||
allergies,
|
||||
payload.hospital_id ? String(payload.hospital_id) : 'act_admin' // fallback tenant
|
||||
];
|
||||
|
||||
const result = await client.query(query, values);
|
||||
return result.rows[0];
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all synced patients from the Pharmacy DB.
|
||||
*/
|
||||
static async getAllPatients(tenantId: string) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `SELECT * FROM patients WHERE tenant_id = $1 ORDER BY created_at DESC;`;
|
||||
const result = await client.query(query, [tenantId]);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { requireAuth } from '../../middleware/auth';
|
||||
import { requireHeaders } from '../../middleware/headers';
|
||||
import { requireIdempotency } from '../../middleware/idempotency';
|
||||
import { InventoryService } from './service';
|
||||
import supplierRouter from './supplierRouter';
|
||||
|
||||
const router = Router();
|
||||
const service = new InventoryService();
|
||||
|
||||
router.use(supplierRouter);
|
||||
|
||||
// POST /v1/inventory/grn
|
||||
router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => {
|
||||
try {
|
||||
@@ -25,7 +28,8 @@ router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req,
|
||||
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);
|
||||
const actorId = req.actorId as string;
|
||||
const batches = await service.getExpiringBatches(days, actorId);
|
||||
res.status(200).json({ batches });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -36,7 +40,8 @@ router.get('/expiring', requireAuth, async (req, res, next) => {
|
||||
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);
|
||||
const actorId = req.actorId as string;
|
||||
const batches = await service.getLowStockBatches(threshold, actorId);
|
||||
res.status(200).json({ batches });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -54,7 +54,7 @@ export class InventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
async getExpiringBatches(daysThreshold: number = 30) {
|
||||
async getExpiringBatches(daysThreshold: number = 30, actorId: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
b.batch_no,
|
||||
@@ -67,14 +67,15 @@ export class InventoryService {
|
||||
JOIN products p ON b.product_id = p.id
|
||||
WHERE b.qty_on_hand > 0
|
||||
AND b.expiry <= CURRENT_DATE + $1::int
|
||||
AND b.tenant_id = $2
|
||||
ORDER BY b.expiry ASC
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(query, [daysThreshold]);
|
||||
const { rows } = await pool.query(query, [daysThreshold, actorId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getLowStockBatches(qtyThreshold: number = 50) {
|
||||
async getLowStockBatches(qtyThreshold: number = 50, actorId: string) {
|
||||
const query = `
|
||||
SELECT
|
||||
b.batch_no,
|
||||
@@ -87,10 +88,11 @@ export class InventoryService {
|
||||
JOIN products p ON b.product_id = p.id
|
||||
WHERE b.qty_on_hand > 0
|
||||
AND b.qty_on_hand <= $1::int
|
||||
AND b.tenant_id = $2
|
||||
ORDER BY b.qty_on_hand ASC
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(query, [qtyThreshold]);
|
||||
const { rows } = await pool.query(query, [qtyThreshold, actorId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -113,20 +115,26 @@ export class InventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
// Lookup pack_size for this product to convert strips/boxes into individual loose units
|
||||
const prodRes = await client.query(`SELECT pack_size FROM products WHERE id = $1`, [line.product_id]);
|
||||
const packSize = (prodRes.rows.length > 0 && parseInt(prodRes.rows[0].pack_size, 10)) ? parseInt(prodRes.rows[0].pack_size, 10) : 1;
|
||||
const totalLooseUnits = line.qty * packSize;
|
||||
|
||||
// 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)
|
||||
INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price, supplier_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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]);
|
||||
purchase_price = EXCLUDED.purchase_price,
|
||||
supplier_id = EXCLUDED.supplier_id
|
||||
`, [line.batch_no, actorId, line.product_id, line.expiry, totalLooseUnits, line.purchase_price, payload.supplier_id || null]);
|
||||
|
||||
// Log Receipt Movement
|
||||
// Log Receipt Movement (in loose units)
|
||||
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]);
|
||||
`, [line.batch_no, totalLooseUnits, grnId]);
|
||||
|
||||
batchesCreated.push(line.batch_no);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth';
|
||||
import { pool } from '../../db/pool';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /v1/inventory/suppliers
|
||||
router.get('/suppliers', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM suppliers WHERE tenant_id = $1 ORDER BY name ASC', [req.actorId]);
|
||||
res.status(200).json({ suppliers: rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /v1/inventory/suppliers
|
||||
router.post('/suppliers', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { name, contact_number, email, gstin, dl_number, address, contact_person } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const id = 'sup_' + Date.now() + Math.floor(Math.random() * 1000);
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO suppliers (id, tenant_id, name, contact_number, email, gstin, dl_number, address, contact_person)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, [id, req.actorId, name, contact_number, email, gstin, dl_number, address, contact_person]);
|
||||
|
||||
res.status(201).json({ id, name, contact_number, email, gstin, dl_number, address, contact_person });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -30,7 +30,8 @@ router.post('/', requireAuth, upload.single('image'), async (req, res, next) =>
|
||||
// GET /v1/prescriptions
|
||||
router.get('/', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const prescriptions = await service.getAllPrescriptions();
|
||||
const tenantId = req.headers['x-tenant-id'] || 'act_admin';
|
||||
const prescriptions = await service.getAllPrescriptions(String(tenantId));
|
||||
res.status(200).json({ prescriptions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -40,7 +41,8 @@ router.get('/', requireAuth, async (req, res, next) => {
|
||||
// GET /v1/prescriptions/:id
|
||||
router.get('/:id', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const record = await service.getPrescription(req.params.id as string);
|
||||
const tenantId = req.headers['x-tenant-id'] || 'act_admin';
|
||||
const record = await service.getPrescription(req.params.id as string, String(tenantId));
|
||||
if (!record) return res.status(404).json({ error: 'NOT_FOUND' });
|
||||
|
||||
res.status(200).json(record);
|
||||
@@ -52,8 +54,8 @@ router.get('/:id', requireAuth, async (req, res, next) => {
|
||||
// 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!);
|
||||
const tenantId = req.headers['x-tenant-id'] || 'act_admin';
|
||||
const result = await service.confirmPrescription(req.params.id as string, req.body, String(tenantId));
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -53,9 +53,9 @@ export class PrescriptionService {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO prescriptions (id, image_path, image_hash, status)
|
||||
VALUES ($1, $2, $3, 'processing')
|
||||
`, [id, s3Url, hash]);
|
||||
INSERT INTO prescriptions (id, tenant_id, image_path, image_hash, status)
|
||||
VALUES ($1, $2, $3, $4, 'processing')
|
||||
`, [id, actorId, s3Url, hash]);
|
||||
|
||||
// Enqueue async extraction job
|
||||
this.enqueueExtraction(id, file);
|
||||
@@ -74,8 +74,8 @@ export class PrescriptionService {
|
||||
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 OpenAI = require('openai');
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
const prompt = `You are an AI assistant for a pharmacy.
|
||||
Extract the information from this handwritten or printed medical prescription.
|
||||
@@ -94,20 +94,23 @@ export class PrescriptionService {
|
||||
]
|
||||
}`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3.6-flash',
|
||||
contents: [
|
||||
prompt,
|
||||
const base64Image = file.buffer.toString('base64');
|
||||
const dataUri = `data:${file.mimetype};base64,${base64Image}`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
inlineData: {
|
||||
data: file.buffer.toString("base64"),
|
||||
mimeType: file.mimetype
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: dataUri } }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
]
|
||||
});
|
||||
|
||||
const rawText = response.text || "{}";
|
||||
const rawText = response.choices[0].message.content || "{}";
|
||||
// Clean up markdown if the AI includes it by mistake
|
||||
const cleanJsonText = rawText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
const extractedData = JSON.parse(cleanJsonText);
|
||||
@@ -123,25 +126,25 @@ export class PrescriptionService {
|
||||
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]);
|
||||
await pool.query(`UPDATE prescriptions SET status = 'failed' 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]);
|
||||
async getPrescription(id: string, actorId: string) {
|
||||
const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1 AND tenant_id = $2`, [id, actorId]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async getAllPrescriptions() {
|
||||
const { rows } = await pool.query(`SELECT * FROM prescriptions ORDER BY created_at DESC`);
|
||||
async getAllPrescriptions(actorId: string) {
|
||||
const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE tenant_id = $1 ORDER BY created_at DESC`, [actorId]);
|
||||
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 { rows } = await pool.query(`SELECT status, extracted_data FROM prescriptions WHERE id = $1 AND tenant_id = $2`, [id, actorId]);
|
||||
const record = rows[0];
|
||||
|
||||
if (!record) throw { status: 404, error: 'NOT_FOUND' };
|
||||
@@ -167,8 +170,8 @@ export class PrescriptionService {
|
||||
await pool.query(`
|
||||
UPDATE prescriptions
|
||||
SET status = 'confirmed', confirmed_data = $1
|
||||
WHERE id = $2
|
||||
`, [confirmedData, id]);
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`, [confirmedData, id, actorId]);
|
||||
|
||||
return { id, status: 'confirmed' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user