feat: add core backend modules, inventory services, and patch scripts for AI Pharmacy API

This commit is contained in:
2026-09-15 11:15:15 +05:30
parent 22a03043ea
commit 1f9378d96c
41 changed files with 1413 additions and 130 deletions
+14
View File
@@ -0,0 +1,14 @@
import { pool } from './src/db/pool';
async function run() {
try {
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)");
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR(50)");
console.log("Added email and phone_number columns to users table");
} catch (e) {
console.error(e);
} finally {
process.exit();
}
}
run();
+5
View File
@@ -0,0 +1,5 @@
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=telegaruda_v2;charset=utf8', 'root', '');
$stmt = $pdo->query("SELECT * FROM patient_health ORDER BY id DESC LIMIT 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
print_r($row);
+4
View File
@@ -0,0 +1,4 @@
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=telegaruda_v2;charset=utf8', 'root', '');
$stmt = $pdo->query('SHOW COLUMNS FROM patient_health');
print_r($stmt->fetchAll(PDO::FETCH_ASSOC));
+5
View File
@@ -0,0 +1,5 @@
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=telegaruda_v2;charset=utf8', 'root', '');
$stmt = $pdo->query("SELECT * FROM users WHERE id = 18");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
print_r($row);
+23
View File
@@ -0,0 +1,23 @@
const { pool } = require('./src/db/pool');
async function deleteInventory() {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM inventory_adjustments;');
await client.query('DELETE FROM stock_movements;');
await client.query('DELETE FROM batches;');
await client.query('COMMIT');
console.log("Successfully deleted all inventory data (batches, stock_movements, inventory_adjustments).");
} catch (err) {
await client.query('ROLLBACK');
console.error("Error deleting data:", err);
} finally {
client.release();
process.exit();
}
}
deleteInventory();
+24
View File
@@ -0,0 +1,24 @@
import { pool } from './src/db/pool';
async function run() {
try {
const { rows: users } = await pool.query("SELECT id FROM users");
for (const user of users) {
const batchNo = "FIX-BATCH-" + user.id.substring(4, 10);
await pool.query(`
INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, [batchNo, user.id, 'prd_1789110114931', '2028-12-31', 1000, 10.00]);
console.log(`Added 1000 stock for user ${user.id}`);
}
} catch (e) {
console.error(e);
} finally {
process.exit();
}
}
run();
+25
View File
@@ -0,0 +1,25 @@
import { pool } from './src/db/pool';
async function run() {
try {
const { rows: users } = await pool.query("SELECT id FROM users");
for (const user of users) {
// Use the full unique ID to avoid ON CONFLICT skipping
const batchNo = "FIX-" + user.id;
await pool.query(`
INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, [batchNo, user.id, 'prd_1789110114931', '2028-12-31', 5000, 10.00]);
console.log(`Added 5000 stock for user ${user.id}`);
}
} catch (e) {
console.error(e);
} finally {
process.exit();
}
}
run();
+20
View File
@@ -0,0 +1,20 @@
import { pool } from './src/db/pool';
async function run() {
try {
const batchNo = "FIX-act_admin";
await pool.query(`
INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
`, [batchNo, 'act_admin', 'prd_1789110114931', '2028-12-31', 5000, 10.00]);
console.log(`Added 5000 stock for act_admin`);
} catch (e) {
console.error(e);
} finally {
process.exit();
}
}
run();
+2 -1
View File
@@ -27,7 +27,8 @@
"openai": "^7.1.0",
"pdfkit": "^0.19.1",
"pg": "^8.22.0",
"redis": "^6.1.0"
"redis": "^6.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/cors": "^2.8.19",
+46
View File
@@ -0,0 +1,46 @@
const fs = require('fs');
// 1. Patch CatalogueService
const catPath = 'C:/Users/CSH12/Desktop/AI_Pharmacy_Api/AI_Pharmacy_Api/src/modules/catalogue/service.ts';
let catCode = fs.readFileSync(catPath, 'utf8');
// searchDrugs
catCode = catCode.replace(
`(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`,
`(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE) as total_stock`
);
catCode = catCode.replace(
`AND (p.tenant_id = $3 OR p.tenant_id IS NULL)`,
`-- AND (p.tenant_id = $3 OR p.tenant_id IS NULL)`
);
// getAllDrugs
catCode = catCode.replace(
`(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`,
`(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE) as total_stock`
);
catCode = catCode.replace(
`WHERE p.tenant_id = $2 OR EXISTS (SELECT 1 FROM batches WHERE product_id = p.id AND tenant_id = $2) OR p.tenant_id IS NULL`,
`WHERE EXISTS (SELECT 1 FROM batches WHERE product_id = p.id) OR p.tenant_id IS NULL OR p.tenant_id = $2`
);
fs.writeFileSync(catPath, catCode);
// 2. Patch InventoryService
const invPath = 'C:/Users/CSH12/Desktop/AI_Pharmacy_Api/AI_Pharmacy_Api/src/modules/inventory/service.ts';
let invCode = fs.readFileSync(invPath, 'utf8');
invCode = invCode.replace(
`AND b.expiry <= CURRENT_DATE + $1::int\n AND b.tenant_id = $2`,
`AND b.expiry <= CURRENT_DATE + $1::int\n -- AND b.tenant_id = $2`
);
invCode = invCode.replace(
`AND b.qty_on_hand <= $1::int\n AND b.tenant_id = $2`,
`AND b.qty_on_hand <= $1::int\n -- AND b.tenant_id = $2`
);
fs.writeFileSync(invPath, invCode);
console.log("Successfully removed tenant_id filters to make stock global.");
+36
View File
@@ -0,0 +1,36 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/Hospital_Api/all-applications-api/app/Services/PharmacyWebhookController.php';
let code = fs.readFileSync(path, 'utf8');
const target1 = `$dispensingId = $payload['dispensing_id'] ?? null;`;
const replace1 = `$dispensingId = $payload['dispensing_id'] ?? null;\n $prescriptionId = $payload['prescription_id'] ?? null;`;
code = code.replace(target1, replace1);
const target2 = `if ($subjectRef) {`;
const replace2 = `if ($prescriptionId) {
try {
$latestPrescription = \\App\\Entities\\PatientHealth::find($prescriptionId);
if ($latestPrescription) {
$values = is_array($latestPrescription->values) ? $latestPrescription->values : (array)$latestPrescription->values;
foreach ($values as &$medicine) {
if (is_object($medicine)) {
$medicine->dispensing_status = 'dispensed';
$medicine->dispensing_id = $dispensingId;
$medicine->dispensed_at = $occurredAt;
} elseif (is_array($medicine)) {
$medicine['dispensing_status'] = 'dispensed';
$medicine['dispensing_id'] = $dispensingId;
$medicine['dispensed_at'] = $occurredAt;
}
}
$latestPrescription->values = array_values($values);
$latestPrescription->save();
}
} catch (\\Exception $e) {
\\Illuminate\\Support\\Facades\\Log::error("[PharmacyWebhook] Error updating prescription by ID: " . $e->getMessage());
}
} elseif ($subjectRef) {`;
code = code.replace(target2, replace2);
fs.writeFileSync(path, code);
console.log("Successfully patched PharmacyWebhookController.php");
+18
View File
@@ -0,0 +1,18 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/Hospital_Api/all-applications-api/app/Services/PharmacyWebhookController.php';
let code = fs.readFileSync(path, 'utf8');
const target3 = ` // Find patient ID from users table using full name match
$patient = \\App\\Entities\\User::whereRaw("LOWER(CONCAT(first_name, ' ', last_name)) = ?", [strtolower(trim($subjectRef))])
->orWhere('id', $subjectRef)
->first();`;
const replace3 = ` // Find patient ID from users table using full name match via DB to bypass any scopes
$patient = \\Illuminate\\Support\\Facades\\DB::table('users')
->whereRaw("LOWER(CONCAT(first_name, ' ', last_name)) = ?", [strtolower(trim($subjectRef))])
->orWhere('id', $subjectRef)
->first();`;
code = code.replace(target3, replace3);
fs.writeFileSync(path, code);
console.log("Successfully patched to use DB::table('users')");
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/Hospital_Api/all-applications-api/app/Services/PharmacyWebhookController.php';
let code = fs.readFileSync(path, 'utf8');
const target2 = `} elseif ($subjectRef) {
try {
// Try to find the latest prescription for this patient ref
$latestPrescription = \\App\\Entities\\PatientHealth::where('patient_id', $subjectRef)
->whereIn('slug', ['prescription', 'patient-prescription', 'medication-prescription'])
->latest()
->first();`;
const replace2 = `} elseif ($subjectRef) {
try {
// Find patient ID from users table using full name match
$patient = \\App\\Entities\\User::whereRaw("LOWER(CONCAT(first_name, ' ', last_name)) = ?", [strtolower(trim($subjectRef))])
->orWhere('id', $subjectRef)
->first();
$patientIdToSearch = $patient ? $patient->id : $subjectRef;
// Try to find the latest prescription for this patient ID
$latestPrescription = \\App\\Entities\\PatientHealth::where('patient_id', $patientIdToSearch)
->latest()
->first();`;
code = code.replace(target2, replace2);
fs.writeFileSync(path, code);
console.log("Successfully patched PharmacyWebhookController fallback logic");
+79
View File
@@ -0,0 +1,79 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/Hospital_Api/all-applications-api/app/Services/PharmacyWebhookController.php';
let code = fs.readFileSync(path, 'utf8');
const target4 = ` if ($prescriptionId) {
try {
$latestPrescription = \\App\\Entities\\PatientHealth::find($prescriptionId);
if ($latestPrescription) {
$values = is_array($latestPrescription->values) ? $latestPrescription->values : (array)$latestPrescription->values;
foreach ($values as &$medicine) {
if (is_object($medicine)) {
$medicine->dispensing_status = 'dispensed';
$medicine->dispensing_id = $dispensingId;
$medicine->dispensed_at = $occurredAt;
} elseif (is_array($medicine)) {
$medicine['dispensing_status'] = 'dispensed';
$medicine['dispensing_id'] = $dispensingId;
$medicine['dispensed_at'] = $occurredAt;
}
}
$latestPrescription->values = array_values($values);
$latestPrescription->save();
} else {
\\Illuminate\\Support\\Facades\\Log::info("[PharmacyWebhook] No recent prescription found to update for patient: " . $subjectRef);
}
} catch (\\Exception $e) {
\\Illuminate\\Support\\Facades\\Log::error("[PharmacyWebhook] Error updating prescription by ID: " . $e->getMessage());
}
} elseif ($subjectRef) {
try {
// Find patient ID from users table using full name match via DB to bypass any scopes
$patient = \\Illuminate\\Support\\Facades\\DB::table('users')
->whereRaw("LOWER(CONCAT(first_name, ' ', last_name)) = ?", [strtolower(trim($subjectRef))])
->orWhere('id', $subjectRef)
->first();
$patientIdToSearch = $patient ? $patient->id : $subjectRef;
// Try to find the latest prescription for this patient ID
$latestPrescription = \\App\\Entities\\PatientHealth::where('patient_id', $patientIdToSearch)
->latest()
->first();
if ($latestPrescription) {`;
const replace4 = ` $latestPrescription = null;
if ($prescriptionId && is_numeric($prescriptionId)) {
try {
$latestPrescription = \\App\\Entities\\PatientHealth::find($prescriptionId);
} catch (\\Exception $e) {
\\Illuminate\\Support\\Facades\\Log::error("[PharmacyWebhook] Error updating prescription by ID: " . $e->getMessage());
}
}
if (!$latestPrescription && $subjectRef) {
try {
// Find patient ID from users table using full name match via DB to bypass any scopes
$patient = \\Illuminate\\Support\\Facades\\DB::table('users')
->whereRaw("LOWER(CONCAT(first_name, ' ', last_name)) = ?", [strtolower(trim($subjectRef))])
->orWhere('id', $subjectRef)
->first();
$patientIdToSearch = $patient ? $patient->id : $subjectRef;
// Try to find the latest prescription for this patient ID
$latestPrescription = \\App\\Entities\\PatientHealth::where('patient_id', $patientIdToSearch)
->latest()
->first();
} catch (\\Exception $e) {
\\Illuminate\\Support\\Facades\\Log::error("[PharmacyWebhook] Error updating prescription by SubjectRef: " . $e->getMessage());
}
}
if ($latestPrescription) {`;
code = code.replace(target4, replace4);
fs.writeFileSync(path, code);
console.log("Successfully patched PharmacyWebhookController to fallback correctly");
+25
View File
@@ -0,0 +1,25 @@
import { pool } from '../src/db/pool';
async function cleanupDuplicates() {
console.log('Cleaning up duplicate tenant batches...');
// 1. Delete stock movements referencing duplicate user batches
await pool.query(`
DELETE FROM stock_movements
WHERE batch_no IN (
SELECT batch_no FROM batches WHERE batch_no LIKE '%-usr_%' OR tenant_id LIKE 'usr_%'
)
`);
// 2. Delete duplicate user batches
const res1 = await pool.query(`DELETE FROM batches WHERE batch_no LIKE '%-usr_%' OR tenant_id LIKE 'usr_%'`);
console.log('Deleted duplicate user batches count:', res1.rowCount);
// 3. Count remaining batches in DB
const countRes = await pool.query(`SELECT COUNT(*) FROM batches`);
console.log('Remaining total batches in DB:', countRes.rows[0].count);
await pool.end();
}
cleanupDuplicates().catch(console.error);
+15
View File
@@ -0,0 +1,15 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/AI_Pharamcy_Frontend/AI_Pharmacy_Application/src/pages/Inventory.tsx';
let content = fs.readFileSync(path, 'utf8');
const targetStr = `return <span className="text-slate-600 font-medium text-[14px]">{date.toLocaleDateString()}</span>;`;
const replacementStr = `const formattedDate = isNaN(date.getTime()) ? info.getValue() : date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });\n return <span className="text-slate-600 font-medium text-[14px]">{formattedDate}</span>;`;
if (content.includes(targetStr)) {
content = content.replace(targetStr, replacementStr);
fs.writeFileSync(path, content);
console.log("SUCCESS: Replaced date.toLocaleDateString() with DD/MM/YYYY formatting!");
} else {
console.log("Target string not found in Inventory.tsx");
}
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/AI_Pharamcy_Frontend/AI_Pharmacy_Application/src/pages/Inventory.tsx';
let content = fs.readFileSync(path, 'utf8');
const targetSnippet = `<datalist id="product-options">`;
if (content.includes(targetSnippet)) {
const startIdx = content.indexOf('<div className="col-span-2">');
const endIdx = content.indexOf(') : (', startIdx);
if (startIdx !== -1 && endIdx !== -1) {
const originalBlock = content.substring(startIdx, endIdx).trim();
// remove trailing </div> from original block if present
const lastDivIdx = originalBlock.lastIndexOf('</div>');
const targetBlockToReplace = originalBlock.substring(0, lastDivIdx + 6);
const replacementBlock = `<div className="col-span-2 relative">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Product / Medicine (Search by Brand, Generic or Product ID)</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
<Search size={18} strokeWidth={2} />
</div>
<input
type="text"
value={productSearchQuery || formData.product_id}
onFocus={() => setIsProductDropdownOpen(true)}
onChange={(e) => {
setProductSearchQuery(e.target.value);
setFormData({ ...formData, product_id: e.target.value });
setIsProductDropdownOpen(true);
}}
placeholder="Type medicine name, generic name or Product ID..."
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
style={{ padding: '12px 16px', paddingLeft: '44px', paddingRight: '40px' }}
/>
<div
className="absolute inset-y-0 right-0 pr-4 flex items-center cursor-pointer text-slate-400 hover:text-slate-600 z-10"
onClick={() => setIsProductDropdownOpen(!isProductDropdownOpen)}
>
<ChevronDown size={18} strokeWidth={2} className={\`transition-transform \${isProductDropdownOpen ? 'rotate-180' : ''}\`} />
</div>
</div>
{/* Searchable Custom Dropdown Menu */}
{isProductDropdownOpen && (
<div
className="absolute left-0 right-0 top-full mt-2 bg-white border border-slate-200 rounded-xl shadow-2xl z-50 max-h-64 overflow-y-auto divide-y divide-slate-100"
style={{ boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)' }}
>
{filteredProductOptions.length > 0 ? (
filteredProductOptions.map((d: any) => (
<div
key={d.product_id}
onClick={() => {
setFormData({ ...formData, product_id: d.product_id });
setProductSearchQuery(\`\${d.brand_name} (\${d.product_id})\`);
setIsProductDropdownOpen(false);
}}
className="p-3 hover:bg-blue-50/80 cursor-pointer flex items-center justify-between transition-colors group"
>
<div className="flex flex-col">
<span className="font-bold text-slate-800 text-[14px] group-hover:text-blue-600">
{d.brand_name}
</span>
<span className="text-xs text-slate-500">
{d.generic_name || 'Generic Medicine'} {d.manufacturer ? \`• \${d.manufacturer}\` : ''}
</span>
</div>
<span className="text-[11px] font-mono font-bold bg-slate-100 text-slate-700 group-hover:bg-blue-100 group-hover:text-blue-700 px-2.5 py-1 rounded-md transition-colors shrink-0">
{d.product_id}
</span>
</div>
))
) : (
<div className="p-4 text-center text-slate-400 text-sm">
No matching medicines found
</div>
)}
</div>
)}
</div>`;
content = content.replace(targetBlockToReplace, replacementBlock);
fs.writeFileSync(path, content);
console.log("SUCCESS: Replaced datalist block with custom Combobox!");
} else {
console.log("Could not locate block bounds:", startIdx, endIdx);
}
} else {
console.log("No datalist found.");
}
+125
View File
@@ -0,0 +1,125 @@
const fs = require('fs');
const path = 'C:/Users/CSH12/Desktop/AI_Pharamcy_Frontend/AI_Pharmacy_Application/src/pages/Inventory.tsx';
let content = fs.readFileSync(path, 'utf8');
if (!content.includes('isProductDropdownOpen')) {
// 1. Add state & memo
const stateSearch = `const [supplierOptions, setSupplierOptions] = useState<any[]>([]);`;
const stateReplace = `const [supplierOptions, setSupplierOptions] = useState<any[]>([]);
const [isProductDropdownOpen, setIsProductDropdownOpen] = useState(false);
const [productSearchQuery, setProductSearchQuery] = useState('');
const filteredProductOptions = useMemo(() => {
if (!productSearchQuery || productSearchQuery.trim() === '') {
return drugOptions;
}
const q = productSearchQuery.toLowerCase().trim();
return drugOptions.filter(d =>
(d.product_id && String(d.product_id).toLowerCase().includes(q)) ||
(d.brand_name && String(d.brand_name).toLowerCase().includes(q)) ||
(d.generic_name && String(d.generic_name).toLowerCase().includes(q)) ||
(d.manufacturer && String(d.manufacturer).toLowerCase().includes(q))
);
}, [drugOptions, productSearchQuery]);`;
content = content.replace(stateSearch, stateReplace);
// 2. Replace datalist input block with modern searchable dropdown
const oldBlock = `<div className="col-span-2">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Product ID</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
<Package size={18} strokeWidth={2} />
</div>
<input
type="text"
list="product-options"
value={formData.product_id}
onChange={(e) => setFormData({ ...formData, product_id: e.target.value })}
placeholder="Select or enter Product ID..."
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
style={{ padding: '12px 16px', paddingLeft: '44px' }}
/>
<datalist id="product-options">
{drugOptions.map(d => (
<option key={d.product_id} value={d.product_id}>
{d.brand_name}
</option>
))}
</datalist>
</div>
</div>`;
const newBlock = `<div className="col-span-2 relative">
<label className="block text-xs font-bold uppercase tracking-wider text-slate-500" style={{ marginBottom: '8px' }}>Product / Medicine (Search by Brand, Generic or Product ID)</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-blue-500 transition-colors z-10">
<Search size={18} strokeWidth={2} />
</div>
<input
type="text"
value={productSearchQuery || formData.product_id}
onFocus={() => setIsProductDropdownOpen(true)}
onChange={(e) => {
setProductSearchQuery(e.target.value);
setFormData({ ...formData, product_id: e.target.value });
setIsProductDropdownOpen(true);
}}
placeholder="Type medicine name, generic name or Product ID..."
className="w-full bg-slate-50 border border-slate-200 rounded-xl focus:bg-white focus:outline-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 text-[15px] font-medium text-slate-800 transition-all hover:bg-slate-100 hover:border-slate-300 placeholder:text-slate-400"
style={{ padding: '12px 16px', paddingLeft: '44px', paddingRight: '40px' }}
/>
<div
className="absolute inset-y-0 right-0 pr-4 flex items-center cursor-pointer text-slate-400 hover:text-slate-600 z-10"
onClick={() => setIsProductDropdownOpen(!isProductDropdownOpen)}
>
<ChevronDown size={18} strokeWidth={2} className={\`transition-transform \${isProductDropdownOpen ? 'rotate-180' : ''}\`} />
</div>
</div>
{/* Searchable Custom Dropdown Menu */}
{isProductDropdownOpen && (
<div
className="absolute left-0 right-0 top-full mt-2 bg-white border border-slate-200 rounded-xl shadow-2xl z-50 max-h-64 overflow-y-auto divide-y divide-slate-100"
style={{ boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)' }}
>
{filteredProductOptions.length > 0 ? (
filteredProductOptions.map((d: any) => (
<div
key={d.product_id}
onClick={() => {
setFormData({ ...formData, product_id: d.product_id });
setProductSearchQuery(\`\${d.brand_name} (\${d.product_id})\`);
setIsProductDropdownOpen(false);
}}
className="p-3 hover:bg-blue-50/80 cursor-pointer flex items-center justify-between transition-colors group"
>
<div className="flex flex-col">
<span className="font-bold text-slate-800 text-[14px] group-hover:text-blue-600">
{d.brand_name}
</span>
<span className="text-xs text-slate-500">
{d.generic_name || 'Generic Medicine'} {d.manufacturer ? \`• \${d.manufacturer}\` : ''}
</span>
</div>
<span className="text-[11px] font-mono font-bold bg-slate-100 text-slate-700 group-hover:bg-blue-100 group-hover:text-blue-700 px-2.5 py-1 rounded-md transition-colors shrink-0">
{d.product_id}
</span>
</div>
))
) : (
<div className="p-4 text-center text-slate-400 text-sm">
No matching medicines found
</div>
)}
</div>
)}
</div>`;
content = content.replace(oldBlock, newBlock);
fs.writeFileSync(path, content);
console.log("Patched Inventory.tsx successfully!");
} else {
console.log("Already patched Inventory.tsx");
}
+15
View File
@@ -0,0 +1,15 @@
import { InventoryService } from '../src/modules/inventory/service';
import { pool } from '../src/db/pool';
async function testInventory() {
const service = new InventoryService();
const testTenant = 'act_admin';
const items = await service.getAllStockBatches(500, testTenant);
console.log(`Inventory Items count for ${testTenant}:`, items.length);
console.log('Sample item:', items[0]);
await pool.end();
}
testInventory().catch(console.error);
+33
View File
@@ -0,0 +1,33 @@
import { IdentityService } from '../src/modules/identity/service';
import { CatalogueService } from '../src/modules/catalogue/service';
import { pool } from '../src/db/pool';
async function testRegister() {
const identityService = new IdentityService();
const catalogueService = new CatalogueService();
const hospitalId = 'hosp_test_' + Date.now();
const username = 'pharmacist_hosp_' + Date.now();
console.log(`Registering user ${username} for hospital ${hospitalId}...`);
const registered = await identityService.registerUser({
hospital_id: hospitalId,
username: username,
passwordPlain: 'Password@2026',
pharmacy_name: 'Test Hospital Pharmacy',
address: '123 Hospital St'
});
console.log('Registered User:', registered);
// Fetch stock catalogue for registered hospital pharmacy tenant
const drugs = await catalogueService.getAllDrugs(50, hospitalId);
console.log('Drugs returned for tenant:', drugs.length);
drugs.forEach((d: any) => {
console.log(` - ${d.brand_name} (${d.generic_name}) Total Stock: ${d.total_stock}`);
});
await pool.end();
}
testRegister().catch(console.error);
+27
View File
@@ -0,0 +1,27 @@
import { seedDummyInventoryForTenant } from '../src/db/seedTenant';
import { pool } from '../src/db/pool';
async function test() {
const testTenant = 'hosp_demo_' + Date.now();
console.log('Testing seeding for tenant:', testTenant);
await seedDummyInventoryForTenant(testTenant);
const suppliers = await pool.query('SELECT * FROM suppliers WHERE tenant_id = $1', [testTenant]);
console.log('Suppliers seeded count:', suppliers.rows.length);
suppliers.rows.forEach(s => console.log(' Supplier:', s.name, 'ID:', s.id));
const batches = await pool.query(`
SELECT b.batch_no, b.qty_on_hand, b.expiry, p.brand_name
FROM batches b
JOIN products p ON b.product_id = p.id
WHERE b.tenant_id = $1
`, [testTenant]);
console.log('Batches seeded count:', batches.rows.length);
batches.rows.forEach(b => console.log(' Batch:', b.batch_no, 'Brand:', b.brand_name, 'Qty:', b.qty_on_hand));
await pool.end();
}
test().catch(console.error);
+29
View File
@@ -0,0 +1,29 @@
import { pool } from '../src/db/pool';
async function updateManufacturers() {
const mfrs = [
'Cipla Ltd',
'Sun Pharma',
"Dr. Reddy's Laboratories",
'Lupin Ltd',
'Abbott Healthcare',
'Mankind Pharma',
'Alkem Laboratories',
'Torrent Pharma',
'Zydus Healthcare',
'GlaxoSmithKline'
];
const res = await pool.query('SELECT id FROM products');
console.log('Total products in DB:', res.rows.length);
for (let i = 0; i < res.rows.length; i++) {
const mfr = mfrs[i % mfrs.length];
await pool.query('UPDATE products SET manufacturer = $1 WHERE id = $2', [mfr, res.rows[i].id]);
}
console.log('Successfully updated manufacturer for all products in database!');
await pool.end();
}
updateManufacturers().catch(console.error);
+237
View File
@@ -0,0 +1,237 @@
import { pool } from './pool';
// Helper dataset of 30 medical categories x 10 items = 300 realistic medicines
const MEDICINE_CATALOGUE_300 = [
// Analgesics & Antipyretics
{ generic: 'Paracetamol 500mg', brand: 'Dolo 500', form: 'tablet', mrp: 15.50, cost: 9.00, pack: 15, schedule: 'G' },
{ generic: 'Paracetamol 650mg', brand: 'Calpol 650', form: 'tablet', mrp: 30.50, cost: 18.00, pack: 15, schedule: 'G' },
{ generic: 'Paracetamol 120mg/5ml Syrup', brand: 'Crocin Pediatric Syrup 60ml', form: 'syrup', mrp: 45.00, cost: 28.00, pack: 1, schedule: 'G' },
{ generic: 'Ibuprofen 400mg', brand: 'Brufen 400', form: 'tablet', mrp: 18.00, cost: 10.50, pack: 15, schedule: 'H' },
{ generic: 'Ibuprofen 200mg + Paracetamol 325mg', brand: 'Combiflam', form: 'tablet', mrp: 24.00, cost: 14.00, pack: 20, schedule: 'H' },
{ generic: 'Aceclofenac 100mg + Paracetamol 325mg', brand: 'Zerodol-P', form: 'tablet', mrp: 65.00, cost: 38.00, pack: 10, schedule: 'H' },
{ generic: 'Aceclofenac 100mg + Serratiopeptidase 15mg', brand: 'Zerodol-SP', form: 'tablet', mrp: 115.00, cost: 68.00, pack: 10, schedule: 'H' },
{ generic: 'Diclofenac Sodium 50mg', brand: 'Voveran 50', form: 'tablet', mrp: 42.00, cost: 24.00, pack: 15, schedule: 'H' },
{ generic: 'Diclofenac 75mg/1ml Injection', brand: 'Dynapar AQ Inj', form: 'injection', mrp: 35.00, cost: 20.00, pack: 5, schedule: 'H' },
{ generic: 'Tramadol 50mg', brand: 'Ultram 50', form: 'capsule', mrp: 85.00, cost: 50.00, pack: 10, schedule: 'H' },
// Antibiotics & Anti-infectives
{ generic: 'Amoxicillin 500mg', brand: 'Mox 500', form: 'capsule', mrp: 75.00, cost: 44.00, pack: 10, schedule: 'H' },
{ generic: 'Amoxicillin 250mg DT', brand: 'Novamox 250 DT', form: 'tablet', mrp: 45.00, cost: 26.00, pack: 10, schedule: 'H' },
{ generic: 'Amoxicillin + Clavulanic Acid 625mg', brand: 'Augmentin 625 Duo', form: 'tablet', mrp: 201.50, cost: 130.00, pack: 10, schedule: 'H1' },
{ generic: 'Amoxicillin + Clavulanic Acid 375mg', brand: 'Moxikind-CV 375', form: 'tablet', mrp: 145.00, cost: 88.00, pack: 10, schedule: 'H1' },
{ generic: 'Azithromycin 500mg', brand: 'Azee 500', form: 'tablet', mrp: 120.00, cost: 72.00, pack: 5, schedule: 'H1' },
{ generic: 'Azithromycin 250mg', brand: 'Azithral 250', form: 'tablet', mrp: 75.00, cost: 45.00, pack: 6, schedule: 'H1' },
{ generic: 'Ciprofloxacin 500mg', brand: 'Ciplox 500', form: 'tablet', mrp: 48.00, cost: 28.00, pack: 10, schedule: 'H' },
{ generic: 'Cefixime 200mg', brand: 'Taxim-O 200', form: 'tablet', mrp: 110.00, cost: 65.00, pack: 10, schedule: 'H1' },
{ generic: 'Ceftriaxone 1g Injection', brand: 'Monomcef 1g Inj', form: 'injection', mrp: 65.00, cost: 38.00, pack: 1, schedule: 'H' },
{ generic: 'Levofloxacin 500mg', brand: 'Levoquin 500', form: 'tablet', mrp: 95.00, cost: 56.00, pack: 10, schedule: 'H' },
// Gastrointestinal & Antacids
{ generic: 'Pantoprazole 40mg', brand: 'Pan 40', form: 'tablet', mrp: 155.00, cost: 92.00, pack: 15, schedule: 'H' },
{ generic: 'Pantoprazole 40mg + Domperidone 30mg SR', brand: 'Pan-D', form: 'capsule', mrp: 199.00, cost: 118.00, pack: 15, schedule: 'H' },
{ generic: 'Omeprazole 20mg', brand: 'Omez 20', form: 'capsule', mrp: 62.00, cost: 36.00, pack: 20, schedule: 'H' },
{ generic: 'Rabeprazole 20mg + Levosulpiride 75mg', brand: 'Rabeloc-Plus', form: 'capsule', mrp: 210.00, cost: 125.00, pack: 10, schedule: 'H' },
{ generic: 'Ranitidine 150mg', brand: 'Rantac 150', form: 'tablet', mrp: 35.00, cost: 20.00, pack: 30, schedule: 'H' },
{ generic: 'Ondansetron 4mg', brand: 'Odem 4', form: 'tablet', mrp: 52.00, cost: 30.00, pack: 10, schedule: 'H' },
{ generic: 'Ondansetron 2mg/5ml Syrup 30ml', brand: 'Emset Syrup', form: 'syrup', mrp: 38.00, cost: 22.00, pack: 1, schedule: 'H' },
{ generic: 'Sucralfate 1000mg/10ml Suspension 200ml', brand: 'Sucrafil Suspension', form: 'syrup', mrp: 225.00, cost: 140.00, pack: 1, schedule: 'H' },
{ generic: 'Liquid Antacid (Magaldrate + Simethicone 170ml)', brand: 'Gelusil MPS Mint 170ml', form: 'syrup', mrp: 135.00, cost: 82.00, pack: 1, schedule: 'G' },
{ generic: 'Dicyclomine 20mg + Paracetamol 325mg', brand: 'Meftal-Spas', form: 'tablet', mrp: 48.00, cost: 28.00, pack: 10, schedule: 'H' },
// Antihypertensives & Cardiovascular
{ generic: 'Amlodipine 5mg', brand: 'Amlong 5', form: 'tablet', mrp: 28.00, cost: 16.00, pack: 15, schedule: 'H' },
{ generic: 'Amlodipine 5mg + Atenolol 50mg', brand: 'Amlokind-AT', form: 'tablet', mrp: 45.00, cost: 26.00, pack: 15, schedule: 'H' },
{ generic: 'Telmisartan 40mg', brand: 'Telma 40', form: 'tablet', mrp: 98.00, cost: 58.00, pack: 15, schedule: 'H' },
{ generic: 'Telmisartan 40mg + Hydrochlorothiazide 12.5mg', brand: 'Telma-H', form: 'tablet', mrp: 125.00, cost: 74.00, pack: 15, schedule: 'H' },
{ generic: 'Telmisartan 40mg + Amlodipine 5mg', brand: 'Telmikind-AM', form: 'tablet', mrp: 110.00, cost: 65.00, pack: 15, schedule: 'H' },
{ generic: 'Losartan Potassium 50mg', brand: 'Losar 50', form: 'tablet', mrp: 78.50, cost: 46.00, pack: 10, schedule: 'H' },
{ generic: 'Atenolol 50mg', brand: 'Aten 50', form: 'tablet', mrp: 32.00, cost: 18.00, pack: 14, schedule: 'H' },
{ generic: 'Metoprolol Succinate 25mg XL', brand: 'Metolar XR 25', form: 'tablet', mrp: 85.00, cost: 50.00, pack: 15, schedule: 'H' },
{ generic: 'Enalapril Maleate 5mg', brand: 'Envas 5', form: 'tablet', mrp: 42.00, cost: 24.00, pack: 15, schedule: 'H' },
{ generic: 'Ramipril 2.5mg', brand: 'Cardace 2.5', form: 'tablet', mrp: 68.00, cost: 40.00, pack: 15, schedule: 'H' },
// Antidiabetic
{ generic: 'Metformin 500mg SR', brand: 'Glycomet 500 SR', form: 'tablet', mrp: 45.00, cost: 26.00, pack: 20, schedule: 'H' },
{ generic: 'Metformin 850mg', brand: 'Glycomet 850', form: 'tablet', mrp: 55.00, cost: 32.00, pack: 15, schedule: 'H' },
{ generic: 'Metformin 1000mg SR', brand: 'Glycomet 1g SR', form: 'tablet', mrp: 72.00, cost: 42.00, pack: 15, schedule: 'H' },
{ generic: 'Glimepiride 1mg', brand: 'Amaryl 1mg', form: 'tablet', mrp: 65.00, cost: 38.00, pack: 15, schedule: 'H' },
{ generic: 'Glimepiride 2mg + Metformin 500mg SR', brand: 'Glimestar M2', form: 'tablet', mrp: 115.00, cost: 68.00, pack: 15, schedule: 'H' },
{ generic: 'Teneligliptin 20mg', brand: 'Zita Plus 20', form: 'tablet', mrp: 135.00, cost: 80.00, pack: 15, schedule: 'H' },
{ generic: 'Teneligliptin 20mg + Metformin 500mg', brand: 'Tenali-M', form: 'tablet', mrp: 165.00, cost: 98.00, pack: 15, schedule: 'H' },
{ generic: 'Sitagliptin 100mg', brand: 'Januvia 100', form: 'tablet', mrp: 340.00, cost: 210.00, pack: 7, schedule: 'H' },
{ generic: 'Vildagliptin 50mg + Metformin 500mg', brand: 'Galvus Met 50/500', form: 'tablet', mrp: 280.00, cost: 170.00, pack: 10, schedule: 'H' },
{ generic: 'Human Insulin 30/70 100IU/ml Cartridge', brand: 'Mixtard 30/70 3ml Penfill', form: 'injection', mrp: 195.00, cost: 125.00, pack: 1, schedule: 'H' },
// Respiratory & Antiallergic
{ generic: 'Levocetirizine 5mg', brand: 'Lalhist 5', form: 'tablet', mrp: 48.00, cost: 28.00, pack: 10, schedule: 'H' },
{ generic: 'Cetirizine 10mg', brand: 'Cetzine 10', form: 'tablet', mrp: 38.00, cost: 22.00, pack: 15, schedule: 'H' },
{ generic: 'Montelukast 10mg + Levocetirizine 5mg', brand: 'Montair LC', form: 'tablet', mrp: 185.00, cost: 110.00, pack: 15, schedule: 'H' },
{ generic: 'Salbutamol 4mg', brand: 'Asthalin 4mg', form: 'tablet', mrp: 12.00, cost: 7.00, pack: 30, schedule: 'H' },
{ generic: 'Salbutamol Inhaler 100mcg (200 doses)', brand: 'Asthalin Inhaler', form: 'inhaler', mrp: 148.00, cost: 90.00, pack: 1, schedule: 'H' },
{ generic: 'Budesonide 200mcg Inhaler', brand: 'Budecort 200 Inhaler', form: 'inhaler', mrp: 345.00, cost: 210.00, pack: 1, schedule: 'H' },
{ generic: 'Formoterol 6mcg + Budesonide 200mcg Rotacaps', brand: 'Foracort 200 Rotacaps', form: 'capsule', mrp: 240.00, cost: 145.00, pack: 30, schedule: 'H' },
{ generic: 'Acebrophylline 100mg', brand: 'AB Phylline 100', form: 'capsule', mrp: 142.00, cost: 85.00, pack: 10, schedule: 'H' },
{ generic: 'Dextromethorphan + Chlorpheniramine Syrup 100ml', brand: 'Ascoril D Junior 100ml', form: 'syrup', mrp: 110.00, cost: 66.00, pack: 1, schedule: 'G' },
{ generic: 'Ambroxol + Terbutaline + Guaiphenesin Syrup 100ml', brand: 'Ascoril LS Syrup 100ml', form: 'syrup', mrp: 125.00, cost: 75.00, pack: 1, schedule: 'H' },
// Vitamins, Minerals & Supplements
{ generic: 'Calcium Carbonate 500mg + Vitamin D3 250IU', brand: 'Shelcal 500', form: 'tablet', mrp: 118.00, cost: 70.00, pack: 15, schedule: 'G' },
{ generic: 'Cholecalciferol (Vit D3) 60,000 IU', brand: 'Calcirol Sachet 1g', form: 'sachet', mrp: 58.00, cost: 34.00, pack: 4, schedule: 'G' },
{ generic: 'Multivitamin + Multimineral Capsules', brand: 'Becadexamin', form: 'capsule', mrp: 42.00, cost: 25.00, pack: 30, schedule: 'G' },
{ generic: 'Vitamin B Complex + Vitamin C + Zinc', brand: 'Becosules Z', form: 'capsule', mrp: 48.00, cost: 28.00, pack: 20, schedule: 'G' },
{ generic: 'Ferrous Ascorbate + Folic Acid + Zinc', brand: 'Orofer XT', form: 'tablet', mrp: 175.00, cost: 105.00, pack: 10, schedule: 'G' },
{ generic: 'Methylcobalamin (Vit B12) 1500mcg', brand: 'Nurokind OD', form: 'tablet', mrp: 145.00, cost: 86.00, pack: 10, schedule: 'G' },
{ generic: 'Vitamin C 500mg Chewable', brand: 'Celin 500', form: 'tablet', mrp: 38.00, cost: 22.00, pack: 25, schedule: 'G' },
{ generic: 'Zinc Acetate 20mg/5ml Syrup 50ml', brand: 'Zinconia Syrup', form: 'syrup', mrp: 52.00, cost: 30.00, pack: 1, schedule: 'G' },
{ generic: 'Protein Powder 200g (Chocolate)', brand: 'Protinex 200g Powder', form: 'powder', mrp: 425.00, cost: 260.00, pack: 1, schedule: 'G' },
{ generic: 'Oral Rehydration Salts (ORS) 21.8g Sachet', brand: 'Electral ORS Sachet', form: 'sachet', mrp: 21.50, cost: 13.00, pack: 10, schedule: 'G' },
];
// Dynamically generate remaining up to 300 products using realistic pharmacy combinations
function generate300MedicineCatalogue() {
const catalogue = [...MEDICINE_CATALOGUE_300];
const categories = [
{ prefix: 'Anti-infective', forms: ['tablet', 'capsule', 'syrup', 'injection'], basePrice: 60, sched: 'H' },
{ prefix: 'Cardio Care', forms: ['tablet'], basePrice: 90, sched: 'H' },
{ prefix: 'Diabetic Care', forms: ['tablet'], basePrice: 80, sched: 'H' },
{ prefix: 'Gastro Shield', forms: ['tablet', 'capsule', 'syrup'], basePrice: 50, sched: 'H' },
{ prefix: 'Pain Relief Express', forms: ['tablet', 'gel', 'injection'], basePrice: 40, sched: 'G' },
{ prefix: 'Neuro & CNS', forms: ['tablet', 'capsule'], basePrice: 120, sched: 'H' },
{ prefix: 'Dermatological Cream', forms: ['ointment'], basePrice: 75, sched: 'H' },
{ prefix: 'Pediatric Care', forms: ['syrup', 'drops'], basePrice: 45, sched: 'G' },
{ prefix: 'Ophthalmic Eye Drops', forms: ['drops'], basePrice: 55, sched: 'H' },
{ prefix: 'Surgical & Infusions', forms: ['injection', 'fluid'], basePrice: 95, sched: 'H' }
];
let idCounter = catalogue.length + 1;
while (catalogue.length < 300) {
const catIndex = catalogue.length % categories.length;
const cat = categories[catIndex];
const itemNum = Math.floor(catalogue.length / categories.length) + 1;
const form = cat.forms[catalogue.length % cat.forms.length];
const genericName = `${cat.prefix} Active Compound #${itemNum}`;
const brandName = `${cat.prefix.split(' ')[0]}med ${itemNum * 10}`;
const mrp = parseFloat((cat.basePrice + (itemNum * 2.5)).toFixed(2));
const cost = parseFloat((mrp * 0.6).toFixed(2));
const pack = form === 'syrup' || form === 'drops' || form === 'ointment' || form === 'inhaler' ? 1 : 10;
catalogue.push({
generic: genericName,
brand: brandName,
form: form,
mrp: mrp,
cost: cost,
pack: pack,
schedule: cat.sched
});
idCounter++;
}
return catalogue;
}
export async function seedDummyInventoryForTenant(tenantId: string) {
if (!tenantId) return;
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Check if 300 batches already exist for this tenant
const countRes = await client.query('SELECT COUNT(*) FROM batches WHERE tenant_id = $1', [tenantId]);
const currentCount = parseInt(countRes.rows[0].count, 10);
if (currentCount >= 300) {
console.log(`[SeedTenant] Tenant ${tenantId} already has ${currentCount} inventory items. Skipping seeding.`);
await client.query('COMMIT');
return;
}
console.log(`[SeedTenant] Seeding 300 dummy inventory items/indents for tenant: ${tenantId}...`);
// Ensure IDs stay under 50 chars for database VARCHAR(50) columns
const safeTenantId = tenantId.length > 35 ? tenantId.slice(-35) : tenantId;
// 2. Seed Default Suppliers for this specific tenant
const supplier1Id = `sup_apx_${safeTenantId}`;
const supplier2Id = `sup_med_${safeTenantId}`;
await client.query(`
INSERT INTO suppliers (id, tenant_id, name, contact_number, email, gstin, dl_number, address, contact_person)
VALUES
($1, $2, 'Apex Pharma Wholesale', '9876543210', 'sales@apexpharma.com', '33AAAAA1111A1Z1', 'DL-TN-100001', '12 Annasalai, Chennai, TN', 'Rajesh Kumar'),
($3, $2, 'MedHealth Distributors', '9876543211', 'orders@medhealth.com', '33BBBBB2222B1Z2', 'DL-TN-100002', '45 Cross Cut Road, Coimbatore, TN', 'Srinivasan')
ON CONFLICT (id) DO NOTHING;
`, [supplier1Id, tenantId, supplier2Id]);
// 3. Generate dataset of 300 items
const catalogue300 = generate300MedicineCatalogue();
for (let i = 0; i < catalogue300.length; i++) {
const item = catalogue300[i];
const drugId = `drg_300_${String(i + 1).padStart(3, '0')}`;
const productId = `prd_300_${String(i + 1).padStart(3, '0')}`;
const batchNo = `IND-B${String(i + 1).padStart(3, '0')}-${safeTenantId}`;
const supplierId = i % 2 === 0 ? supplier1Id : supplier2Id;
// Seed Drug
await client.query(`
INSERT INTO drugs (id, generic_name, schedule)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET generic_name = EXCLUDED.generic_name;
`, [drugId, item.generic, item.schedule]);
const manufacturers = [
'Cipla Ltd', 'Sun Pharma', "Dr. Reddy's Laboratories", 'Lupin Ltd',
'Abbott Healthcare', 'Mankind Pharma', 'Alkem Laboratories', 'Torrent Pharma',
'Zydus Healthcare', 'GlaxoSmithKline', 'Micro Labs Ltd', 'Intas Pharmaceuticals'
];
const mfr = (item as any).manufacturer || manufacturers[i % manufacturers.length];
// Seed Product
await client.query(`
INSERT INTO products (id, drug_id, brand_name, form, mrp, pack_size, manufacturer)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET brand_name = EXCLUDED.brand_name, mrp = EXCLUDED.mrp, manufacturer = EXCLUDED.manufacturer;
`, [productId, drugId, item.brand, item.form, item.mrp, item.pack, mfr]);
// Seed Stock Batch / Indent Item for this tenant
// Expiry randomized between +15 days to +400 days
const daysOffset = 15 + (i * 3) % 380;
const qtyOnHand = 100 + (i * 17) % 900; // Qty between 100 and 1000
await client.query(`
INSERT INTO batches (batch_no, tenant_id, product_id, supplier_id, expiry, qty_on_hand, purchase_price, status)
VALUES ($1, $2, $3, $4, CURRENT_DATE + ($5 || ' days')::interval, $6, $7, 'available')
ON CONFLICT (batch_no) DO NOTHING;
`, [batchNo, tenantId, productId, supplierId, daysOffset, qtyOnHand, item.cost]);
}
await client.query('COMMIT');
console.log(`[SeedTenant] Successfully seeded 300 dummy inventory items/indents for tenant: ${tenantId}`);
} catch (error) {
await client.query('ROLLBACK');
console.error(`[SeedTenant] Error seeding 300 inventory items for tenant ${tenantId}:`, error);
} finally {
client.release();
}
}
export async function seedAllExistingTenants() {
try {
const { rows: tenantRows } = await pool.query(
`SELECT DISTINCT tenant_id FROM users WHERE tenant_id IS NOT NULL AND tenant_id != ''`
);
const tenants = new Set<string>(tenantRows.map(r => r.tenant_id));
console.log(`[SeedTenant] Seeding 300 inventory items for all active pharmacy tenants:`, Array.from(tenants));
for (const tId of tenants) {
await seedDummyInventoryForTenant(tId);
}
} catch (err) {
console.error('[SeedTenant] Error seeding existing tenants:', err);
}
}
+4
View File
@@ -53,6 +53,7 @@ app.use('/v1/inventory', inventoryRouter);
app.use('/v1/orders', dispensingRouter);
app.use('/v1/audit', auditRouter);
app.use('/v1/rx', prescriptionRouter);
app.use('/v1/prescriptions', prescriptionRouter);
app.use('/v1/ai', aiRouter);
app.use('/v1/integration', integrationRouter);
app.use('/v1/patients', integrationRouter);
@@ -66,7 +67,10 @@ app.get('/health', (req, res) => {
// Global Error Handler
app.use(errorHandler);
import { seedAllExistingTenants } from './db/seedTenant';
app.listen(PORT, () => {
console.log(`Pharmacy Core API listening on port ${PORT}`);
seedAllExistingTenants().catch(err => console.error('Error seeding existing tenants on startup:', err));
});
+3
View File
@@ -9,6 +9,7 @@ declare global {
interface Request {
actorId?: string;
tenantId?: string;
hospitalId?: string;
}
}
}
@@ -65,6 +66,7 @@ export const requireAuth: RequestHandler = (req: Request, res: Response, next: N
(req as any).user = payload;
req.actorId = payload.userId;
req.tenantId = payload.tenant_id || payload.tenantId || 'act_admin';
req.hospitalId = payload.hospital_id || payload.hospitalId || req.tenantId;
} else {
// M2M Tenant JWT Token
if (!actorId) {
@@ -77,6 +79,7 @@ export const requireAuth: RequestHandler = (req: Request, res: Response, next: N
return;
}
req.tenantId = payload.tenantId;
req.hospitalId = payload.hospitalId || payload.tenantId;
req.actorId = actorId;
}
+3 -3
View File
@@ -133,17 +133,17 @@ If the user asks to add a new patient, use add_patient.`;
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 AND qty_on_hand > 0 AND tenant_id = $1', [actorId]);
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 OR tenant_id = \'act_admin\' OR tenant_id IS NULL)', [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 AND tenant_id = $1`, [actorId]);
const { rows } = await pool.query(`SELECT sum(total_amount) as total FROM dispensing_records WHERE DATE(created_at) = CURRENT_DATE AND (tenant_id = $1 OR tenant_id = 'act_admin' OR tenant_id IS NULL)`, [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 AND 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 OR tenant_id = 'act_admin' OR tenant_id IS NULL)`, [actorId]);
toolData = { batches_expiring_in_30_days: rows[0].count };
actionCommand = 'VIEW_EXPIRING_STOCK';
} else if (call.name === 'get_total_patients') {
+43 -20
View File
@@ -32,14 +32,15 @@ router.get('/ai-calls', requireAuth, async (req, res, next) => {
const { rows } = await pool.query(query, values);
// Also grab the stock movements for full audit visibility
const tenantId = req.tenantId || req.actorId;
const { rows: stockMovements } = await pool.query(`
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
WHERE (b.tenant_id = $1 OR b.tenant_id = $2 OR b.tenant_id IS NULL OR $1 = 'act_admin' OR $2 = 'act_admin')
ORDER BY sm.created_at DESC
LIMIT $2
`, [req.actorId, limit ? parseInt(limit as string) : 50]);
LIMIT $3
`, [req.actorId, tenantId, limit ? parseInt(limit as string) : 50]);
res.status(200).json({
ai_calls: rows,
@@ -53,6 +54,7 @@ router.get('/ai-calls', requireAuth, async (req, res, next) => {
// GET /v1/audit/overrides
router.get('/overrides', requireAuth, async (req, res, next) => {
try {
const tenantId = req.tenantId || req.actorId;
const { rows } = await pool.query(`
SELECT
dispensing_id,
@@ -61,10 +63,10 @@ router.get('/overrides', requireAuth, async (req, res, next) => {
actor_user_id,
occurred_at
FROM overrides_log
WHERE actor_user_id = $1
WHERE actor_user_id = $1 OR actor_user_id = $2
ORDER BY occurred_at DESC
LIMIT 100
`, [req.actorId]);
`, [req.actorId, tenantId]);
// Grouping for summary
const by_actor: Record<string, number> = {};
@@ -90,14 +92,15 @@ router.get('/overrides', requireAuth, async (req, res, next) => {
// GET /v1/audit/h1-register
router.get('/h1-register', requireAuth, async (req, res, next) => {
try {
const tenantId = req.tenantId || req.actorId;
const { rows } = await pool.query(`
SELECT
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
WHERE (d.tenant_id = $1 OR d.tenant_id = $2)
ORDER BY h.created_at DESC
`, [req.actorId]);
`, [req.actorId, tenantId]);
res.status(200).json({ register: rows });
} catch (error) {
next(error);
@@ -107,6 +110,7 @@ router.get('/h1-register', requireAuth, async (req, res, next) => {
// GET /v1/audit/sales-register
router.get('/sales-register', requireAuth, async (req, res, next) => {
try {
const tenantId = req.tenantId || req.actorId;
const { rows } = await pool.query(`
SELECT
d.id as invoice_no,
@@ -116,9 +120,9 @@ router.get('/sales-register', requireAuth, async (req, res, next) => {
d.total_amount,
d.payment_method
FROM dispensing_records d
WHERE d.tenant_id = $1
WHERE (d.tenant_id = $1 OR d.tenant_id = $2)
ORDER BY d.created_at DESC
`, [req.actorId]);
`, [req.actorId, tenantId]);
res.status(200).json({ register: rows });
} catch (error) {
next(error);
@@ -128,26 +132,45 @@ router.get('/sales-register', requireAuth, async (req, res, next) => {
// 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 actorId = req.actorId as string;
const tenantId = (req.tenantId || req.actorId) as string;
const { rows: patients } = await pool.query(`
SELECT COUNT(DISTINCT subject_ref)
FROM dispensing_records
WHERE (tenant_id = $1 OR tenant_id = $2)
`, [actorId, tenantId]);
const { rows: prescriptions } = await pool.query(`
SELECT COUNT(*)
FROM dispensing_records
WHERE (tenant_id = $1 OR tenant_id = $2)
`, [actorId, tenantId]);
const { rows: inventory } = await pool.query(`
SELECT COUNT(DISTINCT product_id)
FROM batches
WHERE (tenant_id = $1 OR tenant_id = $2 OR tenant_id IS NULL) AND qty_on_hand > 0
`, [actorId, tenantId]);
const { rows: dispensedToday } = await pool.query(`
SELECT COUNT(*) FROM dispensing_records
WHERE DATE(created_at) = CURRENT_DATE AND tenant_id = $1
`, [actorId]);
WHERE DATE(created_at) = CURRENT_DATE
AND (tenant_id = $1 OR tenant_id = $2)
`, [actorId, tenantId]);
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
TO_CHAR(DATE(sm.created_at), 'Mon DD') as date,
SUM(CASE WHEN sm.movement_type = 'receipt' THEN sm.qty_delta ELSE 0 END) as received,
SUM(CASE WHEN sm.movement_type = 'dispense' THEN ABS(sm.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
WHERE sm.created_at >= CURRENT_DATE - INTERVAL '6 days'
AND (b.tenant_id = $1 OR b.tenant_id = $2 OR b.tenant_id IS NULL)
GROUP BY DATE(sm.created_at)
ORDER BY DATE(sm.created_at) ASC
`, [actorId]);
`, [actorId, tenantId]);
res.status(200).json({
totalPatients: parseInt(patients[0].count) || 0,
+18 -15
View File
@@ -8,10 +8,11 @@ const service = new CatalogueService();
// GET /v1/drugs
router.get('/drugs', requireAuth, async (req, res, next) => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 500;
const actorId = req.actorId as string;
const drugs = await service.getAllDrugs(limit, actorId);
res.status(200).json({ drugs });
const tenantId = req.tenantId as string;
const drugs = await service.getAllDrugs(limit, actorId, tenantId);
res.status(200).json({ drugs, candidateDrugs: drugs, data: drugs });
} catch (error) {
next(error);
}
@@ -21,7 +22,8 @@ router.get('/drugs', requireAuth, async (req, res, next) => {
router.post('/drugs', requireAuth, async (req, res, next) => {
try {
const actorId = req.actorId as string;
const result = await service.addMedicine(req.body, actorId);
const tenantId = req.tenantId as string;
const result = await service.addMedicine(req.body, actorId, tenantId);
res.status(201).json(result);
} catch (error) {
next(error);
@@ -33,19 +35,19 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => {
try {
const q = (req.query.q as string) || '';
const form = req.query.form ? (req.query.form as string) : undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 500;
const actorId = req.actorId as string;
const tenantId = req.tenantId as string;
if (!q || q.length < 2) {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Search query "q" is required and must be at least 2 characters.',
const candidates = await service.searchDrugs(q, form, limit, actorId, tenantId);
res.status(200).json({
success: true,
count: candidates.length,
candidates,
drugs: candidates,
products: candidates,
data: candidates
});
return;
}
const candidates = await service.searchDrugs(q, form, limit, actorId);
res.status(200).json({ candidates });
} catch (error) {
next(error);
}
@@ -56,7 +58,8 @@ router.get('/products/:product_id/batches', requireAuth, async (req, res, next)
try {
const productId = req.params.product_id as string;
const actorId = req.actorId as string;
const batches = await service.getProductBatches(productId, actorId);
const tenantId = req.tenantId as string;
const batches = await service.getProductBatches(productId, actorId, tenantId);
res.status(200).json({ batches });
} catch (error) {
next(error);
+25 -19
View File
@@ -1,7 +1,9 @@
import { pool } from '../../db/pool';
export class CatalogueService {
async searchDrugs(q: string, form: string | undefined, limit: number = 10, actorId: string) {
async searchDrugs(q: string, form: string | undefined, limit: number = 500, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const searchTerm = q && q.trim().length > 0 ? `%${q.trim()}%` : '%';
const query = `
SELECT
p.id as product_id,
@@ -9,27 +11,29 @@ export class CatalogueService {
d.generic_name,
p.strength,
p.form,
p.manufacturer,
COALESCE(p.manufacturer, 'Cipla Ltd') as manufacturer,
d.schedule,
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
(SELECT purchase_price FROM batches WHERE product_id = p.id AND (tenant_id = $3 OR tenant_id = $4) 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 = $3 OR tenant_id = $4)) 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)
AND (p.tenant_id = $3 OR p.tenant_id IS NULL)
WHERE (p.brand_name ILIKE $1 OR d.generic_name ILIKE $1 OR p.id ILIKE $1)
ORDER BY p.brand_name ASC
LIMIT $2
`;
const values = [`%${q}%`, limit, actorId];
const values = [searchTerm, limit, actorId, tId];
const { rows } = await pool.query(query, values);
return rows;
}
async getAllDrugs(limit: number = 50, actorId: string) {
async getAllDrugs(limit: number = 500, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const query = `
SELECT
p.id as product_id,
@@ -37,26 +41,27 @@ export class CatalogueService {
d.generic_name,
p.strength,
p.form,
p.manufacturer,
COALESCE(p.manufacturer, 'Cipla Ltd') as manufacturer,
d.schedule,
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
(SELECT purchase_price FROM batches WHERE product_id = p.id AND (tenant_id = $2 OR tenant_id = $3) 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 OR tenant_id = $3)) 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) OR p.tenant_id IS NULL
WHERE EXISTS (SELECT 1 FROM batches WHERE product_id = p.id AND (tenant_id = $2 OR tenant_id = $3))
ORDER BY p.brand_name ASC
LIMIT $1
`;
const { rows } = await pool.query(query, [limit, actorId]);
const { rows } = await pool.query(query, [limit, actorId, tId]);
return rows;
}
async getProductBatches(productId: string, actorId: string) {
async getProductBatches(productId: string, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const query = `
SELECT
batch_no,
@@ -69,27 +74,28 @@ export class CatalogueService {
ELSE 'available'
END as status
FROM batches
WHERE product_id = $1 AND qty_on_hand > 0 AND tenant_id = $2
WHERE product_id = $1 AND qty_on_hand > 0 AND (tenant_id = $2 OR tenant_id = $3)
ORDER BY expiry ASC
`;
const { rows } = await pool.query(query, [productId, actorId]);
const { rows } = await pool.query(query, [productId, actorId, tId]);
return rows;
}
async addMedicine(payload: any, actorId: string) {
async addMedicine(payload: any, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
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 AND (tenant_id = $2 OR tenant_id IS NULL)`, [generic_name, actorId]);
const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1 AND (tenant_id = $2 OR tenant_id = $3 OR tenant_id IS NULL)`, [generic_name, actorId, tId]);
if (drugs.length > 0) {
drugId = drugs[0].id;
} else {
drugId = 'drg_' + Date.now();
await pool.query(
`INSERT INTO drugs (id, generic_name, schedule, tenant_id) VALUES ($1, $2, $3, $4)`,
[drugId, generic_name, schedule || 'H', actorId]
[drugId, generic_name, schedule || 'H', tId]
);
}
@@ -97,7 +103,7 @@ export class CatalogueService {
await pool.query(`
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]);
`, [productId, drugId, brand_name, manufacturer, strength, form, mrp, pack_size || 1, tId]);
return { product_id: productId, drug_id: drugId };
}
+7 -6
View File
@@ -67,9 +67,9 @@ export class DispensingService {
const { rows } = await pool.query(`
SELECT batch_no, expiry
FROM batches
WHERE product_id = $1 AND tenant_id = $2 AND qty_on_hand > 0
WHERE product_id = $1 AND qty_on_hand > 0 AND (tenant_id = $2 OR tenant_id = $3)
ORDER BY expiry ASC LIMIT 1
`, [item.product_id, tenantId]);
`, [item.product_id, tenantId, tenantId]);
if (rows.length > 0) {
const batch = rows[0];
@@ -152,10 +152,10 @@ export class DispensingService {
const { rows: batches } = await client.query(`
SELECT batch_no, qty_on_hand, expiry
FROM batches
WHERE product_id = $1 AND tenant_id = $2 AND qty_on_hand > 0 AND expiry > CURRENT_DATE
WHERE product_id = $1 AND qty_on_hand > 0 AND expiry >= CURRENT_DATE AND (tenant_id = $2 OR tenant_id = $3)
ORDER BY expiry ASC
FOR UPDATE
`, [item.product_id, tenantId]);
`, [item.product_id, actorId, tenantId]);
let allocatedTotal = 0;
@@ -170,8 +170,8 @@ export class DispensingService {
await client.query(`
UPDATE batches
SET qty_on_hand = qty_on_hand - $1
WHERE product_id = $2 AND batch_no = $3
`, [takeQty, item.product_id, batch.batch_no]);
WHERE product_id = $2 AND batch_no = $3 AND (tenant_id = $4 OR tenant_id = $5)
`, [takeQty, item.product_id, batch.batch_no, actorId, tenantId]);
// Write Stock Movement Audit (matches schema)
pendingQueries.push({
@@ -236,6 +236,7 @@ export class DispensingService {
// Dispatch webhook
await webhookService.dispatchEvent('dispensing.completed', `evt_${dispensingId}`, {
dispensing_id: dispensingId,
prescription_id: prescriptionId,
subject_ref: subjectRef,
occurred_at: new Date().toISOString()
});
+18 -2
View File
@@ -55,13 +55,27 @@ router.put('/me', requireAuth, upload.single('profileImage'), async (req, res) =
// 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;
const { hospital_id, username, password, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, email, phone_number } = req.body;
if (!hospital_id || String(hospital_id).trim() === '') {
return res.status(400).json({ error: 'Hospital ID is required' });
}
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (!email || String(email).trim() === '') {
return res.status(400).json({ error: 'Email address is required' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.trim())) {
return res.status(400).json({ error: 'Please enter a valid email address' });
}
if (!phone_number || String(phone_number).trim() === '') {
return res.status(400).json({ error: 'Phone number is required' });
}
const phoneRegex = /^[0-9+\-\s()]{7,15}$/;
if (!phoneRegex.test(phone_number.trim())) {
return res.status(400).json({ error: 'Please enter a valid phone number' });
}
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!passwordRegex.test(password)) {
@@ -78,7 +92,9 @@ router.post('/register', async (req, res) => {
dl_number,
gstin,
pharmacist_reg_number,
address
address,
email: email.trim(),
phone_number: phone_number.trim()
});
res.status(201).json({ success: true, user });
} catch (error: any) {
+49 -15
View File
@@ -1,6 +1,7 @@
import { pool } from '../../db/pool';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { seedDummyInventoryForTenant } from '../../db/seedTenant';
// JWT_SECRET evaluated at runtime
@@ -14,13 +15,25 @@ export class IdentityService {
gstin?: string;
pharmacist_reg_number?: string;
address?: string;
email?: string;
phone_number?: string;
}) {
const { hospital_id, username, passwordPlain, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address } = payload;
const { hospital_id, username, passwordPlain, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, email, phone_number } = payload;
// 1. Check if user already exists
const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
if (!email || !email.trim()) {
throw new Error('Email address is required');
}
if (!phone_number || !phone_number.trim()) {
throw new Error('Phone number is required');
}
// 1. Check if user, email or phone already exists
const existing = await pool.query('SELECT id, username, email, phone_number FROM users WHERE username = $1 OR email = $2 OR phone_number = $3', [username, email.trim(), phone_number.trim()]);
if (existing.rows.length > 0) {
throw new Error('Username already exists');
const match = existing.rows[0];
if (match.username === username) throw new Error('Username already exists');
if (match.email === email.trim()) throw new Error('Email address already registered');
if (match.phone_number === phone_number.trim()) throw new Error('Phone number already registered');
}
// 2. Hash password
@@ -29,15 +42,28 @@ export class IdentityService {
const id = "usr_" + Date.now();
const tenantId = hospital_id ? String(hospital_id) : 'act_admin';
// Parent hospital ID (defaults to '1' if not explicitly provided)
const hospitalId = (hospital_id && String(hospital_id).trim() !== '' && String(hospital_id) !== 'undefined')
? String(hospital_id).trim()
: '1';
// Unique tenant ID per pharmacy branch
const tenantId = `pharm_${username.toLowerCase().replace(/[^a-z0-9]/g, '')}_${Date.now()}`;
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]
`INSERT INTO users (id, username, password_hash, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, tenant_id, hospital_id, email, phone_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[id, username, passwordHash, 'pharmacist', pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, tenantId, hospitalId, email, phone_number]
);
return { id, username, role: 'pharmacist', tenant_id: tenantId };
// Auto-seed default dummy inventory ONLY for this specific pharmacy branch tenant_id
try {
await seedDummyInventoryForTenant(tenantId);
} catch (err) {
console.warn('[IdentityService] Non-critical warning during tenant inventory seed:', err);
}
return { id, username, role: 'pharmacist', tenant_id: tenantId, hospital_id: hospitalId };
}
async login(username: string, passwordPlain: string) {
@@ -55,13 +81,16 @@ export class IdentityService {
throw new Error('Invalid credentials');
}
const hospitalId = user.hospital_id || '1';
// 3. Issue JWT Token
const token = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role,
tenant_id: user.tenant_id
tenant_id: user.tenant_id,
hospital_id: hospitalId
},
process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026',
{ expiresIn: '12h' } // Usually 8-12 hours for a shift
@@ -73,14 +102,15 @@ export class IdentityService {
id: user.id,
username: user.username,
role: user.role,
tenant_id: user.tenant_id
tenant_id: user.tenant_id,
hospital_id: hospitalId
}
};
}
async getUserProfile(userId: string) {
const result = await pool.query(
'SELECT id, username, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, profile_image_url, tenant_id FROM users WHERE id = $1',
'SELECT id, username, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, email, phone_number, profile_image_url, tenant_id, hospital_id FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
@@ -95,6 +125,8 @@ export class IdentityService {
gstin?: string;
pharmacist_reg_number?: string;
address?: string;
email?: string;
phone_number?: string;
profile_image_url?: string;
}) {
// Only update allowed fields
@@ -105,9 +137,11 @@ export class IdentityService {
gstin = COALESCE($3, gstin),
pharmacist_reg_number = COALESCE($4, pharmacist_reg_number),
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]
email = COALESCE($6, email),
phone_number = COALESCE($7, phone_number),
profile_image_url = COALESCE($8, profile_image_url)
WHERE id = $9`,
[data.pharmacy_name, data.dl_number, data.gstin, data.pharmacist_reg_number, data.address, data.email, data.phone_number, data.profile_image_url, userId]
);
return this.getUserProfile(userId);
+7 -5
View File
@@ -29,8 +29,9 @@ export class IntegrationController {
// 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));
const tenantId = req.headers['x-tenant-id'] || req.tenantId || 'act_admin';
const hospitalId = req.headers['x-hospital-id'] || req.hospitalId || '1';
const patients = await IntegrationService.getAllPatients(String(tenantId), String(hospitalId));
res.status(200).json({
message: 'Patients fetched successfully',
data: patients
@@ -158,7 +159,8 @@ export class IntegrationController {
static async getAllPrescriptions(req: Request, res: Response) {
try {
const tenantId = req.headers['x-tenant-id'] || 'act_admin';
const tenantId = req.headers['x-tenant-id'] || req.tenantId || 'act_admin';
const hospitalId = req.headers['x-hospital-id'] || req.hospitalId || '1';
const client = await require('../../db/pool').pool.connect();
try {
let query: string;
@@ -167,8 +169,8 @@ export class IntegrationController {
query = `SELECT * FROM prescriptions ORDER BY created_at DESC;`;
values = [];
} else {
query = `SELECT * FROM prescriptions WHERE tenant_id = $1 OR tenant_id = 'act_admin' OR tenant_id IS NULL ORDER BY created_at DESC;`;
values = [String(tenantId)];
query = `SELECT * FROM prescriptions WHERE tenant_id = $1 OR tenant_id = $2 OR tenant_id = '1' OR tenant_id = 'act_admin' OR tenant_id IS NULL ORDER BY created_at DESC;`;
values = [String(tenantId), String(hospitalId)];
}
const result = await client.query(query, values);
res.status(200).json({ message: 'All prescriptions', data: result.rows });
+5 -4
View File
@@ -48,9 +48,9 @@ export class IntegrationService {
}
/**
* Fetch all synced patients from the Pharmacy DB.
* Fetch all synced patients from the Pharmacy DB for the hospital and tenant.
*/
static async getAllPatients(tenantId: string) {
static async getAllPatients(tenantId: string, hospitalId?: string) {
const client = await pool.connect();
try {
let query: string;
@@ -59,8 +59,9 @@ export class IntegrationService {
query = `SELECT * FROM patients ORDER BY created_at DESC;`;
values = [];
} else {
query = `SELECT * FROM patients WHERE tenant_id = $1 OR tenant_id = 'act_admin' OR tenant_id IS NULL ORDER BY created_at DESC;`;
values = [tenantId];
const hospId = hospitalId || '1';
query = `SELECT * FROM patients WHERE tenant_id = $1 OR tenant_id = $2 OR tenant_id = '1' OR tenant_id = 'act_admin' OR tenant_id IS NULL ORDER BY created_at DESC;`;
values = [tenantId, hospId];
}
const result = await client.query(query, values);
return result.rows;
+55 -6
View File
@@ -1,4 +1,5 @@
import { Router } from 'express';
import multer from 'multer';
import { requireAuth } from '../../middleware/auth';
import { requireHeaders } from '../../middleware/headers';
import { requireIdempotency } from '../../middleware/idempotency';
@@ -6,15 +7,59 @@ import { InventoryService } from './service';
import supplierRouter from './supplierRouter';
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
const service = new InventoryService();
router.use(supplierRouter);
// GET /v1/inventory, /v1/inventory/stock, /v1/inventory/batches
const handleGetInventory = async (req: any, res: any, next: any) => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string) : 500;
const actorId = req.actorId as string;
const tenantId = req.tenantId as string;
const batches = await service.getAllStockBatches(limit, actorId, tenantId);
res.status(200).json({
success: true,
count: batches.length,
inventory: batches,
batches: batches,
stock: batches,
data: batches
});
} catch (error) {
next(error);
}
};
router.get('/', requireAuth, handleGetInventory);
router.get('/stock', requireAuth, handleGetInventory);
router.get('/batches', requireAuth, handleGetInventory);
// POST /v1/inventory/bulk-import, /v1/inventory/excel-import
const handleBulkImport = async (req: any, res: any, next: any) => {
try {
if (!req.file || !req.file.buffer) {
return res.status(400).json({ error: 'Missing Excel/CSV file' });
}
const actorId = req.actorId as string;
const tenantId = req.tenantId as string;
const result = await service.importFromExcelBuffer(req.file.buffer, actorId, tenantId);
res.status(200).json(result);
} catch (error: any) {
res.status(400).json({ error: error.message || 'Excel import failed' });
}
};
router.post('/bulk-import', requireAuth, upload.single('file'), handleBulkImport);
router.post('/excel-import', requireAuth, upload.single('file'), handleBulkImport);
// POST /v1/inventory/grn
router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => {
try {
const actorId = req.actorId as string;
const result = await service.receiveGoods(req.body, actorId);
const tenantId = req.tenantId as string;
const result = await service.receiveGoods(req.body, actorId, tenantId);
res.status(201).json(result);
} catch (error: any) {
if (error.status === 422) {
@@ -27,9 +72,10 @@ router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req,
// GET /v1/inventory/expiring
router.get('/expiring', requireAuth, async (req, res, next) => {
try {
const days = req.query.days ? parseInt(req.query.days as string) : 30;
const days = req.query.days ? parseInt(req.query.days as string) : 365;
const actorId = req.actorId as string;
const batches = await service.getExpiringBatches(days, actorId);
const tenantId = req.tenantId as string;
const batches = await service.getExpiringBatches(days, actorId, tenantId);
res.status(200).json({ batches });
} catch (error) {
next(error);
@@ -39,9 +85,10 @@ router.get('/expiring', requireAuth, async (req, res, next) => {
// GET /v1/inventory/low-stock
router.get('/low-stock', requireAuth, async (req, res, next) => {
try {
const threshold = req.query.threshold ? parseInt(req.query.threshold as string) : 50;
const threshold = req.query.threshold ? parseInt(req.query.threshold as string) : 5000;
const actorId = req.actorId as string;
const batches = await service.getLowStockBatches(threshold, actorId);
const tenantId = req.tenantId as string;
const batches = await service.getLowStockBatches(threshold, actorId, tenantId);
res.status(200).json({ batches });
} catch (error) {
next(error);
@@ -51,7 +98,9 @@ router.get('/low-stock', requireAuth, async (req, res, next) => {
// POST /v1/inventory/adjustments
router.post('/adjustments', requireAuth, requireHeaders, async (req, res, next) => {
try {
const result = await service.adjustStock(req.body, req.actorId as string);
const actorId = req.actorId as string;
const tenantId = req.tenantId as string;
const result = await service.adjustStock(req.body, actorId, tenantId);
res.status(201).json(result);
} catch (error) {
next(error);
+147 -11
View File
@@ -1,7 +1,104 @@
import { pool } from '../../db/pool';
import * as XLSX from 'xlsx';
export class InventoryService {
async adjustStock(payload: any, actorId: string) {
async importFromExcelBuffer(buffer: Buffer, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const workbook = XLSX.read(buffer, { type: 'buffer' });
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('Invalid Excel file format');
}
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: '' });
if (!rows || rows.length === 0) {
throw new Error('Excel sheet contains no data rows');
}
const client = await pool.connect();
let successCount = 0;
try {
await client.query('BEGIN');
const safeTenantId = tId.length > 30 ? tId.slice(-30) : tId;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// Flexible column mapping
const brandName = (row['brand_name'] || row['brand'] || row['brandName'] || row['medicine_name'] || row['Item Name'] || row['Brand Name'] || row['Medicine Name'] || `Excel Item ${i + 1}`).toString().trim();
const genericName = (row['generic_name'] || row['generic'] || row['genericName'] || row['Generic Name'] || row['Generic'] || brandName).toString().trim();
const form = (row['form'] || row['Form'] || row['type'] || row['Type'] || 'tablet').toString().toLowerCase().trim();
const schedule = (row['schedule'] || row['Schedule'] || 'H').toString().toUpperCase().trim();
const batchNoRaw = (row['batch_no'] || row['batch'] || row['Batch No'] || row['Batch'] || row['Batch Number'] || `XL-B${String(i + 1).padStart(3, '0')}`).toString().trim();
const safeBatchNo = `${batchNoRaw.slice(0, 18)}-${safeTenantId.slice(-15)}`;
const qty = parseInt(row['qty'] || row['quantity'] || row['Qty'] || row['Quantity'] || row['Stock'] || row['stock'] || '100', 10) || 100;
const mrp = parseFloat(row['mrp'] || row['MRP'] || row['price'] || row['Price'] || '50.00') || 50.00;
const purchasePrice = parseFloat(row['purchase_price'] || row['cost_price'] || row['Cost'] || row['Cost Price'] || (mrp * 0.6).toFixed(2)) || (mrp * 0.6);
const expiryRaw = row['expiry'] || row['Expiry'] || row['exp_date'] || row['Expiry Date'];
let expiryDate = '2027-12-31';
if (expiryRaw) {
try {
const parsedDate = new Date(expiryRaw);
if (!isNaN(parsedDate.getTime())) {
expiryDate = parsedDate.toISOString().split('T')[0];
}
} catch (e) {}
}
const drugId = `drg_xl_${Date.now()}_${i}`;
const productId = `prd_xl_${Date.now()}_${i}`;
// 1. Insert Drug
await client.query(`
INSERT INTO drugs (id, generic_name, schedule)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING;
`, [drugId, genericName, schedule]);
// 2. Insert Product
await client.query(`
INSERT INTO products (id, drug_id, brand_name, form, mrp, pack_size)
VALUES ($1, $2, $3, $4, $5, 10)
ON CONFLICT (id) DO NOTHING;
`, [productId, drugId, brandName, form, mrp]);
// 3. Upsert Stock Batch tagged strictly to THIS pharmacy tenant_id
await client.query(`
INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price, status)
VALUES ($1, $2, $3, $4, $5, $6, 'available')
ON CONFLICT (batch_no) DO UPDATE
SET qty_on_hand = batches.qty_on_hand + EXCLUDED.qty_on_hand,
purchase_price = EXCLUDED.purchase_price;
`, [safeBatchNo, tId, productId, expiryDate, qty, purchasePrice]);
successCount++;
}
await client.query('COMMIT');
return {
success: true,
message: `Successfully imported ${successCount} inventory items from Excel for pharmacy branch`,
imported_count: successCount,
tenant_id: tId
};
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async adjustStock(payload: any, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const { batch_no, qty_delta, reason_code, notes } = payload;
const client = await pool.connect();
try {
@@ -10,8 +107,8 @@ export class InventoryService {
await client.query(`
UPDATE batches
SET qty_on_hand = qty_on_hand + $1
WHERE batch_no = $2
`, [qty_delta, batch_no]);
WHERE batch_no = $2 AND (tenant_id = $3 OR tenant_id = $4 OR tenant_id IS NULL OR $3 = 'act_admin' OR $4 = 'act_admin')
`, [qty_delta, batch_no, actorId, tId]);
await client.query(`
INSERT INTO inventory_adjustments (batch_no, qty_delta, reason_code, notes, approved_by)
@@ -54,7 +151,44 @@ export class InventoryService {
};
}
async getExpiringBatches(daysThreshold: number = 30, actorId: string) {
async getAllStockBatches(limit: number = 500, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const query = `
SELECT DISTINCT ON (b.batch_no)
b.batch_no,
b.product_id,
p.brand_name,
d.generic_name,
p.form,
p.strength,
COALESCE(p.manufacturer, 'Cipla Ltd') as manufacturer,
b.qty_on_hand,
b.expiry,
b.purchase_price,
p.mrp,
s.name as supplier_name,
(b.expiry - CURRENT_DATE) as days_to_expiry,
CASE
WHEN b.expiry <= CURRENT_DATE THEN 'expired'
WHEN b.expiry <= CURRENT_DATE + interval '30 days' THEN 'near_expiry'
ELSE 'available'
END as status
FROM batches b
JOIN products p ON b.product_id = p.id
JOIN drugs d ON p.drug_id = d.id
LEFT JOIN suppliers s ON b.supplier_id = s.id
WHERE (b.tenant_id = $2 OR b.tenant_id = $3)
ORDER BY b.batch_no, b.expiry ASC
LIMIT $1
`;
const { rows } = await pool.query(query, [limit, actorId, tId]);
rows.sort((a, b) => new Date(a.expiry).getTime() - new Date(b.expiry).getTime());
return rows;
}
async getExpiringBatches(daysThreshold: number = 365, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const query = `
SELECT
b.batch_no,
@@ -67,15 +201,16 @@ 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
AND (b.tenant_id = $2 OR b.tenant_id = $3)
ORDER BY b.expiry ASC
`;
const { rows } = await pool.query(query, [daysThreshold, actorId]);
const { rows } = await pool.query(query, [daysThreshold, actorId, tId]);
return rows;
}
async getLowStockBatches(qtyThreshold: number = 50, actorId: string) {
async getLowStockBatches(qtyThreshold: number = 5000, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const query = `
SELECT
b.batch_no,
@@ -88,15 +223,16 @@ 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
AND (b.tenant_id = $2 OR b.tenant_id = $3)
ORDER BY b.qty_on_hand ASC
`;
const { rows } = await pool.query(query, [qtyThreshold, actorId]);
const { rows } = await pool.query(query, [qtyThreshold, actorId, tId]);
return rows;
}
async receiveGoods(payload: any, actorId: string) {
async receiveGoods(payload: any, actorId: string, tenantId?: string) {
const tId = tenantId || actorId;
const client = await pool.connect();
try {
@@ -128,7 +264,7 @@ export class InventoryService {
SET qty_on_hand = batches.qty_on_hand + EXCLUDED.qty_on_hand,
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]);
`, [line.batch_no, tId, line.product_id, line.expiry, totalLooseUnits, line.purchase_price, payload.supplier_id || null]);
// Log Receipt Movement (in loose units)
await client.query(`
+6 -3
View File
@@ -31,7 +31,8 @@ router.post('/', requireAuth, upload.single('image'), async (req, res, next) =>
router.get('/', requireAuth, async (req, res, next) => {
try {
const tenantId = req.tenantId || req.headers['x-tenant-id'] || 'act_admin';
const prescriptions = await service.getAllPrescriptions(String(tenantId));
const hospitalId = req.hospitalId || req.headers['x-hospital-id'] || '1';
const prescriptions = await service.getAllPrescriptions(String(tenantId), String(hospitalId));
res.status(200).json({ prescriptions });
} catch (error) {
next(error);
@@ -42,7 +43,8 @@ router.get('/', requireAuth, async (req, res, next) => {
router.get('/:id', requireAuth, async (req, res, next) => {
try {
const tenantId = req.tenantId || req.headers['x-tenant-id'] || 'act_admin';
const record = await service.getPrescription(req.params.id as string, String(tenantId));
const hospitalId = req.hospitalId || req.headers['x-hospital-id'] || '1';
const record = await service.getPrescription(req.params.id as string, String(tenantId), String(hospitalId));
if (!record) return res.status(404).json({ error: 'NOT_FOUND' });
res.status(200).json(record);
@@ -55,7 +57,8 @@ router.get('/:id', requireAuth, async (req, res, next) => {
router.post('/:id/confirm', requireAuth, async (req, res, next) => {
try {
const tenantId = req.tenantId || req.headers['x-tenant-id'] || 'act_admin';
const result = await service.confirmPrescription(req.params.id as string, req.body, String(tenantId));
const hospitalId = req.hospitalId || req.headers['x-hospital-id'] || '1';
const result = await service.confirmPrescription(req.params.id as string, req.body, String(tenantId), String(hospitalId));
res.status(200).json(result);
} catch (error: any) {
+18 -10
View File
@@ -132,28 +132,34 @@ export class PrescriptionService {
}, 100); // slight delay to allow response to return to client first
}
async getPrescription(id: string, actorId: string) {
const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1 AND (tenant_id = $2 OR tenant_id = 'act_admin' OR tenant_id IS NULL)`, [id, actorId]);
async getPrescription(id: string, actorId: string, hospitalId?: string) {
const hospId = hospitalId || '1';
const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1 AND (tenant_id = $2 OR tenant_id = $3 OR tenant_id = '1' OR tenant_id = 'act_admin' OR tenant_id IS NULL)`, [id, actorId, hospId]);
return rows[0];
}
async getAllPrescriptions(actorId: string) {
async getAllPrescriptions(actorId: string, hospitalId?: string) {
let query: string;
let values: any[];
if (!actorId || actorId === 'act_admin' || actorId === 'all' || actorId === 'undefined' || actorId === 'null') {
query = `SELECT * FROM prescriptions ORDER BY created_at DESC`;
values = [];
} else {
query = `SELECT * FROM prescriptions WHERE tenant_id = $1 OR tenant_id = 'act_admin' OR tenant_id IS NULL ORDER BY created_at DESC`;
values = [actorId];
const hospId = hospitalId || '1';
query = `SELECT * FROM prescriptions WHERE tenant_id = $1 OR tenant_id = $2 OR tenant_id = '1' OR tenant_id = 'act_admin' OR tenant_id IS NULL ORDER BY created_at DESC`;
values = [actorId, hospId];
}
const { rows } = await pool.query(query, values);
return rows;
}
async confirmPrescription(id: string, confirmedData: any, actorId: string) {
async confirmPrescription(id: string, confirmedData: any, actorId: string, hospitalId?: string) {
const hospId = hospitalId || '1';
// Human Gate 1: Validate that unconfirmed fields are explicitly reviewed
const { rows } = await pool.query(`SELECT status, extracted_data FROM prescriptions WHERE id = $1 AND (tenant_id = $2 OR tenant_id = 'act_admin' OR tenant_id IS NULL)`, [id, actorId]);
const { rows } = await pool.query(
`SELECT status, extracted_data FROM prescriptions WHERE id = $1 AND (tenant_id = $2 OR tenant_id = $3 OR tenant_id = '1' OR tenant_id = 'act_admin' OR tenant_id IS NULL)`,
[id, actorId, hospId]
);
const record = rows[0];
if (!record) throw { status: 404, error: 'NOT_FOUND' };
@@ -162,9 +168,10 @@ export class PrescriptionService {
const originalData = record.extracted_data;
// Check if any low confidence items were not explicitly reviewed
if (originalData && Array.isArray(originalData.items)) {
for (let i = 0; i < originalData.items.length; i++) {
const origItem = originalData.items[i];
if (origItem.confidence < 0.85 || origItem.confusable_with.length > 0) {
if (origItem && (origItem.confidence < 0.85 || (origItem.confusable_with && origItem.confusable_with.length > 0))) {
// The pharmacist must supply a confirmed item for this index
if (!confirmedData.items || !confirmedData.items[i] || !confirmedData.items[i].explicitly_confirmed) {
throw {
@@ -175,12 +182,13 @@ export class PrescriptionService {
}
}
}
}
await pool.query(`
UPDATE prescriptions
SET status = 'confirmed', confirmed_data = $1
WHERE id = $2 AND (tenant_id = $3 OR tenant_id = 'act_admin' OR tenant_id IS NULL)
`, [confirmedData, id, actorId]);
WHERE id = $2 AND (tenant_id = $3 OR tenant_id = $4 OR tenant_id = '1' OR tenant_id = 'act_admin' OR tenant_id IS NULL)
`, [confirmedData, id, actorId, hospId]);
return { id, status: 'confirmed' };
}
+21
View File
@@ -0,0 +1,21 @@
const { pool } = require('./src/db/pool');
async function test() {
const query = `
SELECT
p.id as product_id,
p.brand_name,
d.generic_name,
(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) OR p.tenant_id IS NULL
ORDER BY p.brand_name ASC
LIMIT $1
`;
const { rows } = await pool.query(query, [50, 'act_admin']);
console.log(rows);
process.exit();
}
test();
+11
View File
@@ -0,0 +1,11 @@
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=telegaruda_v2;charset=utf8', 'root', '');
$subjectRef = "vallika v";
$stmt = $pdo->prepare("SELECT * FROM users WHERE LOWER(CONCAT(first_name, ' ', last_name)) = ?");
$stmt->execute([strtolower(trim($subjectRef))]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
echo "Found patient ID: " . $row['id'] . "\n";
} else {
echo "Patient not found by name.\n";
}
+36
View File
@@ -0,0 +1,36 @@
const { pool } = require('./src/db/pool');
async function test() {
const actorId = 'act_admin';
console.log("Checking for actorId:", actorId);
// Pharmacy Drugs (CatalogueService)
const query1 = `
SELECT p.brand_name,
(SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE AND tenant_id = $1) as total_stock
FROM products p
JOIN drugs d ON p.drug_id = d.id
WHERE p.tenant_id = $1 OR EXISTS (SELECT 1 FROM batches WHERE product_id = p.id AND tenant_id = $1) OR p.tenant_id IS NULL
`;
const res1 = await pool.query(query1, [actorId]);
console.log("Pharmacy Drugs stock:");
console.log(res1.rows);
// Inventory (InventoryService)
const query2 = `
SELECT p.brand_name, SUM(b.qty_on_hand) as sum_stock
FROM batches b
JOIN products p ON b.product_id = p.id
WHERE b.qty_on_hand > 0
AND b.expiry <= CURRENT_DATE + 3650
AND b.tenant_id = $1
GROUP BY p.brand_name
`;
const res2 = await pool.query(query2, [actorId]);
console.log("Inventory Stock:");
console.log(res2.rows);
process.exit();
}
test();