[CL-007] feat: Implement pharmacy module

Add complete pharmacy module for erp-clinicas with:

Entities:
- Medication: catalog with controlled substance flags, categories, forms
- PharmacyInventory: stock management with batch/lot tracking, alerts
- PharmacyPrescription: linked to consultation and patient
- Dispensation: medication dispensing with partial fills support

Services:
- MedicationService: CRUD for medication catalog
- PharmacyInventoryService: stock management, low stock alerts, expiry tracking
- PrescriptionService: prescription workflow, refills, controlled substances
- DispensationService: dispensing workflow, verification, returns

Features:
- Multi-tenant support (tenantId in all queries)
- Controlled substance tracking and scheduling
- Inventory with low stock and expiration alerts
- Prescription linked to consultation and patient
- Dispensation tracking with partial fills support
- Stock reservation and dispensing workflow
- FIFO dispensing based on expiration dates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 19:30:45 -06:00
parent 27ea1fd4a6
commit 50a409bf17
15 changed files with 3301 additions and 0 deletions

View File

@ -0,0 +1 @@
export { PharmacyController } from './pharmacy.controller';

View File

@ -0,0 +1,768 @@
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import {
MedicationService,
PharmacyInventoryService,
PrescriptionService,
DispensationService,
} from '../services';
import {
CreateMedicationDto,
UpdateMedicationDto,
MedicationQueryDto,
CreateInventoryDto,
UpdateInventoryDto,
AdjustInventoryDto,
RestockInventoryDto,
InventoryQueryDto,
CreatePrescriptionDto,
UpdatePrescriptionDto,
CancelPrescriptionDto,
PrescriptionQueryDto,
CreateDispensationDto,
VerifyDispensationDto,
CompleteDispensationDto,
CancelDispensationDto,
ReturnDispensationDto,
DispensationQueryDto,
} from '../dto';
export class PharmacyController {
public router: Router;
private medicationService: MedicationService;
private inventoryService: PharmacyInventoryService;
private prescriptionService: PrescriptionService;
private dispensationService: DispensationService;
constructor(dataSource: DataSource, basePath: string = '/api') {
this.router = Router();
this.medicationService = new MedicationService(dataSource);
this.inventoryService = new PharmacyInventoryService(dataSource);
this.prescriptionService = new PrescriptionService(dataSource);
this.dispensationService = new DispensationService(dataSource);
this.setupRoutes(basePath);
}
private setupRoutes(basePath: string): void {
const pharmacyPath = `${basePath}/pharmacy`;
// Medication Routes
this.router.get(`${pharmacyPath}/medications`, this.findAllMedications.bind(this));
this.router.get(`${pharmacyPath}/medications/categories`, this.getMedicationCategories.bind(this));
this.router.get(`${pharmacyPath}/medications/controlled`, this.getControlledSubstances.bind(this));
this.router.get(`${pharmacyPath}/medications/search`, this.searchMedications.bind(this));
this.router.get(`${pharmacyPath}/medications/:id`, this.findMedicationById.bind(this));
this.router.post(`${pharmacyPath}/medications`, this.createMedication.bind(this));
this.router.patch(`${pharmacyPath}/medications/:id`, this.updateMedication.bind(this));
this.router.delete(`${pharmacyPath}/medications/:id`, this.deleteMedication.bind(this));
// Inventory Routes
this.router.get(`${pharmacyPath}/inventory`, this.findAllInventory.bind(this));
this.router.get(`${pharmacyPath}/inventory/summary`, this.getInventorySummary.bind(this));
this.router.get(`${pharmacyPath}/inventory/low-stock`, this.getLowStockAlerts.bind(this));
this.router.get(`${pharmacyPath}/inventory/expiring`, this.getExpiringItems.bind(this));
this.router.get(`${pharmacyPath}/inventory/expired`, this.getExpiredItems.bind(this));
this.router.get(`${pharmacyPath}/inventory/medication/:medicationId`, this.findInventoryByMedication.bind(this));
this.router.get(`${pharmacyPath}/inventory/:id`, this.findInventoryById.bind(this));
this.router.post(`${pharmacyPath}/inventory`, this.createInventory.bind(this));
this.router.patch(`${pharmacyPath}/inventory/:id`, this.updateInventory.bind(this));
this.router.post(`${pharmacyPath}/inventory/:id/adjust`, this.adjustInventory.bind(this));
this.router.post(`${pharmacyPath}/inventory/:id/restock`, this.restockInventory.bind(this));
// Prescription Routes
this.router.get(`${pharmacyPath}/prescriptions`, this.findAllPrescriptions.bind(this));
this.router.get(`${pharmacyPath}/prescriptions/stats`, this.getPrescriptionStats.bind(this));
this.router.get(`${pharmacyPath}/prescriptions/pending`, this.getPendingPrescriptions.bind(this));
this.router.get(`${pharmacyPath}/prescriptions/controlled`, this.getControlledSubstancePrescriptions.bind(this));
this.router.get(`${pharmacyPath}/prescriptions/patient/:patientId`, this.findPrescriptionsByPatient.bind(this));
this.router.get(`${pharmacyPath}/prescriptions/consultation/:consultationId`, this.findPrescriptionsByConsultation.bind(this));
this.router.get(`${pharmacyPath}/prescriptions/:id`, this.findPrescriptionById.bind(this));
this.router.post(`${pharmacyPath}/prescriptions`, this.createPrescription.bind(this));
this.router.patch(`${pharmacyPath}/prescriptions/:id`, this.updatePrescription.bind(this));
this.router.post(`${pharmacyPath}/prescriptions/:id/cancel`, this.cancelPrescription.bind(this));
this.router.post(`${pharmacyPath}/prescriptions/:id/refill`, this.useRefill.bind(this));
// Dispensation Routes
this.router.get(`${pharmacyPath}/dispensations`, this.findAllDispensations.bind(this));
this.router.get(`${pharmacyPath}/dispensations/stats`, this.getDispensationStats.bind(this));
this.router.get(`${pharmacyPath}/dispensations/pending`, this.getPendingDispensations.bind(this));
this.router.get(`${pharmacyPath}/dispensations/patient/:patientId`, this.findDispensationsByPatient.bind(this));
this.router.get(`${pharmacyPath}/dispensations/patient/:patientId/history`, this.getPatientDispensationHistory.bind(this));
this.router.get(`${pharmacyPath}/dispensations/prescription/:prescriptionId`, this.findDispensationsByPrescription.bind(this));
this.router.get(`${pharmacyPath}/dispensations/:id`, this.findDispensationById.bind(this));
this.router.post(`${pharmacyPath}/dispensations`, this.createDispensation.bind(this));
this.router.post(`${pharmacyPath}/dispensations/:id/verify`, this.verifyDispensation.bind(this));
this.router.post(`${pharmacyPath}/dispensations/:id/complete`, this.completeDispensation.bind(this));
this.router.post(`${pharmacyPath}/dispensations/:id/cancel`, this.cancelDispensation.bind(this));
this.router.post(`${pharmacyPath}/dispensations/:id/return`, this.returnDispensation.bind(this));
}
private getTenantId(req: Request): string {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new Error('x-tenant-id header is required');
}
return tenantId;
}
// Medication Handlers
private async findAllMedications(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const query: MedicationQueryDto = {
search: req.query.search as string,
category: req.query.category as any,
form: req.query.form as any,
status: req.query.status as any,
isControlledSubstance: req.query.isControlledSubstance === 'true' ? true : req.query.isControlledSubstance === 'false' ? false : undefined,
requiresPrescription: req.query.requiresPrescription === 'true' ? true : req.query.requiresPrescription === 'false' ? false : undefined,
page: req.query.page ? parseInt(req.query.page as string) : 1,
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
};
const result = await this.medicationService.findAll(tenantId, query);
res.json({
data: result.data,
meta: {
total: result.total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(result.total / (query.limit || 50)),
},
});
} catch (error) {
next(error);
}
}
private async getMedicationCategories(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const categories = await this.medicationService.getCategories(tenantId);
res.json({ data: categories });
} catch (error) {
next(error);
}
}
private async getControlledSubstances(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const medications = await this.medicationService.findControlledSubstances(tenantId);
res.json({ data: medications });
} catch (error) {
next(error);
}
}
private async searchMedications(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const searchTerm = req.query.q as string;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
if (!searchTerm) {
res.status(400).json({ error: 'Search term is required', code: 'SEARCH_TERM_REQUIRED' });
return;
}
const medications = await this.medicationService.searchByName(tenantId, searchTerm, limit);
res.json({ data: medications });
} catch (error) {
next(error);
}
}
private async findMedicationById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const medication = await this.medicationService.findById(tenantId, id);
if (!medication) {
res.status(404).json({ error: 'Medication not found', code: 'MEDICATION_NOT_FOUND' });
return;
}
res.json({ data: medication });
} catch (error) {
next(error);
}
}
private async createMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const dto: CreateMedicationDto = req.body;
const medication = await this.medicationService.create(tenantId, dto);
res.status(201).json({ data: medication });
} catch (error) {
next(error);
}
}
private async updateMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: UpdateMedicationDto = req.body;
const medication = await this.medicationService.update(tenantId, id, dto);
if (!medication) {
res.status(404).json({ error: 'Medication not found', code: 'MEDICATION_NOT_FOUND' });
return;
}
res.json({ data: medication });
} catch (error) {
next(error);
}
}
private async deleteMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const deleted = await this.medicationService.softDelete(tenantId, id);
if (!deleted) {
res.status(404).json({ error: 'Medication not found', code: 'MEDICATION_NOT_FOUND' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
}
// Inventory Handlers
private async findAllInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const query: InventoryQueryDto = {
medicationId: req.query.medicationId as string,
status: req.query.status as any,
lowStockOnly: req.query.lowStockOnly === 'true',
expiringOnly: req.query.expiringOnly === 'true',
expiringWithinDays: req.query.expiringWithinDays ? parseInt(req.query.expiringWithinDays as string) : undefined,
location: req.query.location as string,
page: req.query.page ? parseInt(req.query.page as string) : 1,
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
};
const result = await this.inventoryService.findAll(tenantId, query);
res.json({
data: result.data,
meta: {
total: result.total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(result.total / (query.limit || 50)),
},
});
} catch (error) {
next(error);
}
}
private async getInventorySummary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const summary = await this.inventoryService.getInventorySummary(tenantId);
res.json({ data: summary });
} catch (error) {
next(error);
}
}
private async getLowStockAlerts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const items = await this.inventoryService.getLowStockAlerts(tenantId);
res.json({ data: items });
} catch (error) {
next(error);
}
}
private async getExpiringItems(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const withinDays = req.query.days ? parseInt(req.query.days as string) : 30;
const items = await this.inventoryService.getExpiringItems(tenantId, withinDays);
res.json({ data: items });
} catch (error) {
next(error);
}
}
private async getExpiredItems(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const items = await this.inventoryService.getExpiredItems(tenantId);
res.json({ data: items });
} catch (error) {
next(error);
}
}
private async findInventoryByMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { medicationId } = req.params;
const items = await this.inventoryService.findByMedication(tenantId, medicationId);
res.json({ data: items });
} catch (error) {
next(error);
}
}
private async findInventoryById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const inventory = await this.inventoryService.findById(tenantId, id);
if (!inventory) {
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
return;
}
res.json({ data: inventory });
} catch (error) {
next(error);
}
}
private async createInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const dto: CreateInventoryDto = req.body;
const inventory = await this.inventoryService.create(tenantId, dto);
res.status(201).json({ data: inventory });
} catch (error) {
next(error);
}
}
private async updateInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: UpdateInventoryDto = req.body;
const inventory = await this.inventoryService.update(tenantId, id, dto);
if (!inventory) {
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
return;
}
res.json({ data: inventory });
} catch (error) {
next(error);
}
}
private async adjustInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: AdjustInventoryDto = req.body;
const inventory = await this.inventoryService.adjustStock(tenantId, id, dto);
if (!inventory) {
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
return;
}
res.json({ data: inventory });
} catch (error) {
next(error);
}
}
private async restockInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: RestockInventoryDto = req.body;
const inventory = await this.inventoryService.restock(tenantId, id, dto);
if (!inventory) {
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
return;
}
res.json({ data: inventory });
} catch (error) {
next(error);
}
}
// Prescription Handlers
private async findAllPrescriptions(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const query: PrescriptionQueryDto = {
patientId: req.query.patientId as string,
doctorId: req.query.doctorId as string,
consultationId: req.query.consultationId as string,
medicationId: req.query.medicationId as string,
dateFrom: req.query.dateFrom as string,
dateTo: req.query.dateTo as string,
status: req.query.status as any,
priority: req.query.priority as any,
isControlledSubstance: req.query.isControlledSubstance === 'true' ? true : req.query.isControlledSubstance === 'false' ? false : undefined,
page: req.query.page ? parseInt(req.query.page as string) : 1,
limit: req.query.limit ? parseInt(req.query.limit as string) : 20,
};
const result = await this.prescriptionService.findAll(tenantId, query);
res.json({
data: result.data,
meta: {
total: result.total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(result.total / (query.limit || 20)),
},
});
} catch (error) {
next(error);
}
}
private async getPrescriptionStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const stats = await this.prescriptionService.getPrescriptionStats(tenantId);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
private async getPendingPrescriptions(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const prescriptions = await this.prescriptionService.findPendingPrescriptions(tenantId);
res.json({ data: prescriptions });
} catch (error) {
next(error);
}
}
private async getControlledSubstancePrescriptions(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const dateFrom = req.query.dateFrom as string;
const dateTo = req.query.dateTo as string;
const prescriptions = await this.prescriptionService.findControlledSubstancePrescriptions(tenantId, dateFrom, dateTo);
res.json({ data: prescriptions });
} catch (error) {
next(error);
}
}
private async findPrescriptionsByPatient(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { patientId } = req.params;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
const prescriptions = await this.prescriptionService.findByPatient(tenantId, patientId, limit);
res.json({ data: prescriptions });
} catch (error) {
next(error);
}
}
private async findPrescriptionsByConsultation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { consultationId } = req.params;
const prescriptions = await this.prescriptionService.findByConsultation(tenantId, consultationId);
res.json({ data: prescriptions });
} catch (error) {
next(error);
}
}
private async findPrescriptionById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const prescription = await this.prescriptionService.findById(tenantId, id);
if (!prescription) {
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
return;
}
res.json({ data: prescription });
} catch (error) {
next(error);
}
}
private async createPrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const dto: CreatePrescriptionDto = req.body;
const prescription = await this.prescriptionService.create(tenantId, dto);
res.status(201).json({ data: prescription });
} catch (error) {
next(error);
}
}
private async updatePrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: UpdatePrescriptionDto = req.body;
const prescription = await this.prescriptionService.update(tenantId, id, dto);
if (!prescription) {
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
return;
}
res.json({ data: prescription });
} catch (error) {
next(error);
}
}
private async cancelPrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: CancelPrescriptionDto = req.body;
const prescription = await this.prescriptionService.cancel(tenantId, id, dto);
if (!prescription) {
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
return;
}
res.json({ data: prescription });
} catch (error) {
next(error);
}
}
private async useRefill(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const prescription = await this.prescriptionService.useRefill(tenantId, id);
if (!prescription) {
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
return;
}
res.json({ data: prescription });
} catch (error) {
next(error);
}
}
// Dispensation Handlers
private async findAllDispensations(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const query: DispensationQueryDto = {
prescriptionId: req.query.prescriptionId as string,
patientId: req.query.patientId as string,
medicationId: req.query.medicationId as string,
dateFrom: req.query.dateFrom as string,
dateTo: req.query.dateTo as string,
status: req.query.status as any,
type: req.query.type as any,
page: req.query.page ? parseInt(req.query.page as string) : 1,
limit: req.query.limit ? parseInt(req.query.limit as string) : 20,
};
const result = await this.dispensationService.findAll(tenantId, query);
res.json({
data: result.data,
meta: {
total: result.total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(result.total / (query.limit || 20)),
},
});
} catch (error) {
next(error);
}
}
private async getDispensationStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const stats = await this.dispensationService.getDispensationStats(tenantId);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
private async getPendingDispensations(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const dispensations = await this.dispensationService.findPendingDispensations(tenantId);
res.json({ data: dispensations });
} catch (error) {
next(error);
}
}
private async findDispensationsByPatient(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { patientId } = req.params;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
const dispensations = await this.dispensationService.findByPatient(tenantId, patientId, limit);
res.json({ data: dispensations });
} catch (error) {
next(error);
}
}
private async getPatientDispensationHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { patientId } = req.params;
const medicationId = req.query.medicationId as string;
const history = await this.dispensationService.getPatientDispensationHistory(tenantId, patientId, medicationId);
res.json({ data: history });
} catch (error) {
next(error);
}
}
private async findDispensationsByPrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { prescriptionId } = req.params;
const dispensations = await this.dispensationService.findByPrescription(tenantId, prescriptionId);
res.json({ data: dispensations });
} catch (error) {
next(error);
}
}
private async findDispensationById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dispensation = await this.dispensationService.findById(tenantId, id);
if (!dispensation) {
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
return;
}
res.json({ data: dispensation });
} catch (error) {
next(error);
}
}
private async createDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const dto: CreateDispensationDto = req.body;
const dispensation = await this.dispensationService.create(tenantId, dto);
res.status(201).json({ data: dispensation });
} catch (error) {
next(error);
}
}
private async verifyDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: VerifyDispensationDto = req.body;
const dispensation = await this.dispensationService.verify(tenantId, id, dto);
if (!dispensation) {
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
return;
}
res.json({ data: dispensation });
} catch (error) {
next(error);
}
}
private async completeDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: CompleteDispensationDto = req.body;
const dispensation = await this.dispensationService.complete(tenantId, id, dto);
if (!dispensation) {
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
return;
}
res.json({ data: dispensation });
} catch (error) {
next(error);
}
}
private async cancelDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: CancelDispensationDto = req.body;
const dispensation = await this.dispensationService.cancel(tenantId, id, dto);
if (!dispensation) {
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
return;
}
res.json({ data: dispensation });
} catch (error) {
next(error);
}
}
private async returnDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = this.getTenantId(req);
const { id } = req.params;
const dto: ReturnDispensationDto = req.body;
const dispensation = await this.dispensationService.processReturn(tenantId, id, dto);
if (!dispensation) {
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
return;
}
res.json({ data: dispensation });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,785 @@
import {
IsString,
IsOptional,
IsUUID,
IsEnum,
IsBoolean,
IsDateString,
IsInt,
IsNumber,
IsArray,
MaxLength,
Min,
} from 'class-validator';
import {
MedicationCategory,
MedicationStatus,
MedicationForm,
ControlledSubstanceSchedule,
InventoryStatus,
PharmacyPrescriptionStatus,
PrescriptionPriority,
DispensationStatus,
DispensationType,
} from '../entities';
// Medication DTOs
export class CreateMedicationDto {
@IsString()
@MaxLength(50)
code: string;
@IsString()
@MaxLength(200)
name: string;
@IsOptional()
@IsString()
@MaxLength(200)
genericName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
brandName?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'])
category?: MedicationCategory;
@IsOptional()
@IsEnum(['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'])
form?: MedicationForm;
@IsOptional()
@IsString()
@MaxLength(100)
strength?: string;
@IsOptional()
@IsString()
@MaxLength(50)
unit?: string;
@IsOptional()
@IsString()
@MaxLength(300)
activeIngredient?: string;
@IsOptional()
@IsString()
@MaxLength(200)
manufacturer?: string;
@IsOptional()
@IsBoolean()
isControlledSubstance?: boolean;
@IsOptional()
@IsEnum(['none', 'schedule_i', 'schedule_ii', 'schedule_iii', 'schedule_iv', 'schedule_v'])
controlledSchedule?: ControlledSubstanceSchedule;
@IsOptional()
@IsBoolean()
requiresPrescription?: boolean;
@IsOptional()
@IsString()
storageConditions?: string;
@IsOptional()
@IsString()
contraindications?: string;
@IsOptional()
@IsString()
sideEffects?: string;
@IsOptional()
@IsNumber()
@Min(0)
price?: number;
@IsOptional()
@IsNumber()
@Min(0)
costPrice?: number;
@IsOptional()
@IsInt()
displayOrder?: number;
}
export class UpdateMedicationDto {
@IsOptional()
@IsString()
@MaxLength(50)
code?: string;
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@IsOptional()
@IsString()
@MaxLength(200)
genericName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
brandName?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'])
category?: MedicationCategory;
@IsOptional()
@IsEnum(['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'])
form?: MedicationForm;
@IsOptional()
@IsString()
@MaxLength(100)
strength?: string;
@IsOptional()
@IsString()
@MaxLength(50)
unit?: string;
@IsOptional()
@IsString()
@MaxLength(300)
activeIngredient?: string;
@IsOptional()
@IsString()
@MaxLength(200)
manufacturer?: string;
@IsOptional()
@IsBoolean()
isControlledSubstance?: boolean;
@IsOptional()
@IsEnum(['none', 'schedule_i', 'schedule_ii', 'schedule_iii', 'schedule_iv', 'schedule_v'])
controlledSchedule?: ControlledSubstanceSchedule;
@IsOptional()
@IsBoolean()
requiresPrescription?: boolean;
@IsOptional()
@IsString()
storageConditions?: string;
@IsOptional()
@IsString()
contraindications?: string;
@IsOptional()
@IsString()
sideEffects?: string;
@IsOptional()
@IsNumber()
@Min(0)
price?: number;
@IsOptional()
@IsNumber()
@Min(0)
costPrice?: number;
@IsOptional()
@IsEnum(['active', 'inactive', 'discontinued'])
status?: MedicationStatus;
@IsOptional()
@IsInt()
displayOrder?: number;
}
export class MedicationQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'])
category?: MedicationCategory;
@IsOptional()
@IsEnum(['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'])
form?: MedicationForm;
@IsOptional()
@IsEnum(['active', 'inactive', 'discontinued'])
status?: MedicationStatus;
@IsOptional()
@IsBoolean()
isControlledSubstance?: boolean;
@IsOptional()
@IsBoolean()
requiresPrescription?: boolean;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
// Inventory DTOs
export class CreateInventoryDto {
@IsUUID()
medicationId: string;
@IsOptional()
@IsString()
@MaxLength(100)
batchNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
lotNumber?: string;
@IsInt()
@Min(0)
quantityOnHand: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderQuantity?: number;
@IsOptional()
@IsInt()
@Min(0)
maximumStock?: number;
@IsOptional()
@IsString()
@MaxLength(50)
unitOfMeasure?: string;
@IsOptional()
@IsNumber()
@Min(0)
costPerUnit?: number;
@IsOptional()
@IsDateString()
expirationDate?: string;
@IsOptional()
@IsDateString()
manufactureDate?: string;
@IsOptional()
@IsString()
@MaxLength(200)
location?: string;
@IsOptional()
@IsString()
@MaxLength(100)
shelfLocation?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateInventoryDto {
@IsOptional()
@IsString()
@MaxLength(100)
batchNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
lotNumber?: string;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderQuantity?: number;
@IsOptional()
@IsInt()
@Min(0)
maximumStock?: number;
@IsOptional()
@IsString()
@MaxLength(50)
unitOfMeasure?: string;
@IsOptional()
@IsNumber()
@Min(0)
costPerUnit?: number;
@IsOptional()
@IsDateString()
expirationDate?: string;
@IsOptional()
@IsString()
@MaxLength(200)
location?: string;
@IsOptional()
@IsString()
@MaxLength(100)
shelfLocation?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class AdjustInventoryDto {
@IsInt()
adjustment: number;
@IsString()
@MaxLength(500)
reason: string;
@IsOptional()
@IsString()
@MaxLength(100)
reference?: string;
@IsOptional()
@IsUUID()
performedBy?: string;
}
export class RestockInventoryDto {
@IsInt()
@Min(1)
quantity: number;
@IsOptional()
@IsString()
@MaxLength(100)
batchNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
lotNumber?: string;
@IsOptional()
@IsDateString()
expirationDate?: string;
@IsOptional()
@IsNumber()
@Min(0)
costPerUnit?: number;
@IsOptional()
@IsString()
@MaxLength(100)
reference?: string;
@IsOptional()
@IsUUID()
performedBy?: string;
}
export class InventoryQueryDto {
@IsOptional()
@IsUUID()
medicationId?: string;
@IsOptional()
@IsEnum(['in_stock', 'low_stock', 'out_of_stock', 'expired'])
status?: InventoryStatus;
@IsOptional()
@IsBoolean()
lowStockOnly?: boolean;
@IsOptional()
@IsBoolean()
expiringOnly?: boolean;
@IsOptional()
@IsInt()
expiringWithinDays?: number;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
// Prescription DTOs
export class CreatePrescriptionDto {
@IsUUID()
patientId: string;
@IsOptional()
@IsUUID()
consultationId?: string;
@IsOptional()
@IsUUID()
originalPrescriptionId?: string;
@IsUUID()
prescribingDoctorId: string;
@IsUUID()
medicationId: string;
@IsString()
@MaxLength(200)
medicationName: string;
@IsOptional()
@IsString()
@MaxLength(50)
medicationCode?: string;
@IsInt()
@Min(1)
quantityPrescribed: number;
@IsString()
@MaxLength(100)
dosage: string;
@IsString()
@MaxLength(100)
frequency: string;
@IsOptional()
@IsString()
@MaxLength(100)
duration?: string;
@IsOptional()
@IsString()
@MaxLength(100)
routeOfAdministration?: string;
@IsOptional()
@IsString()
instructions?: string;
@IsOptional()
@IsInt()
@Min(0)
refillsAllowed?: number;
@IsOptional()
@IsBoolean()
isControlledSubstance?: boolean;
@IsOptional()
@IsEnum(['routine', 'urgent', 'stat'])
priority?: PrescriptionPriority;
@IsOptional()
@IsString()
clinicalNotes?: string;
@IsOptional()
@IsDateString()
expirationDate?: string;
}
export class UpdatePrescriptionDto {
@IsOptional()
@IsInt()
@Min(1)
quantityPrescribed?: number;
@IsOptional()
@IsString()
@MaxLength(100)
dosage?: string;
@IsOptional()
@IsString()
@MaxLength(100)
frequency?: string;
@IsOptional()
@IsString()
@MaxLength(100)
duration?: string;
@IsOptional()
@IsString()
@MaxLength(100)
routeOfAdministration?: string;
@IsOptional()
@IsString()
instructions?: string;
@IsOptional()
@IsInt()
@Min(0)
refillsAllowed?: number;
@IsOptional()
@IsEnum(['routine', 'urgent', 'stat'])
priority?: PrescriptionPriority;
@IsOptional()
@IsString()
clinicalNotes?: string;
@IsOptional()
@IsDateString()
expirationDate?: string;
}
export class CancelPrescriptionDto {
@IsString()
@MaxLength(500)
reason: string;
}
export class PrescriptionQueryDto {
@IsOptional()
@IsUUID()
patientId?: string;
@IsOptional()
@IsUUID()
doctorId?: string;
@IsOptional()
@IsUUID()
consultationId?: string;
@IsOptional()
@IsUUID()
medicationId?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsEnum(['pending', 'partially_filled', 'filled', 'cancelled', 'expired'])
status?: PharmacyPrescriptionStatus;
@IsOptional()
@IsEnum(['routine', 'urgent', 'stat'])
priority?: PrescriptionPriority;
@IsOptional()
@IsBoolean()
isControlledSubstance?: boolean;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}
// Dispensation DTOs
export class CreateDispensationDto {
@IsUUID()
prescriptionId: string;
@IsOptional()
@IsUUID()
inventoryId?: string;
@IsInt()
@Min(1)
quantityRequested: number;
@IsInt()
@Min(1)
quantityDispensed: number;
@IsOptional()
@IsEnum(['full', 'partial', 'refill'])
type?: DispensationType;
@IsOptional()
@IsUUID()
dispensedBy?: string;
@IsOptional()
@IsString()
@MaxLength(200)
receivedBy?: string;
@IsOptional()
@IsString()
@MaxLength(100)
receiverRelationship?: string;
@IsOptional()
@IsString()
@MaxLength(100)
receiverIdentification?: string;
@IsOptional()
@IsNumber()
@Min(0)
unitPrice?: number;
@IsOptional()
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
insuranceCovered?: number;
@IsOptional()
@IsString()
instructions?: string;
@IsOptional()
@IsBoolean()
counselingProvided?: boolean;
@IsOptional()
@IsString()
counselingNotes?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class VerifyDispensationDto {
@IsUUID()
verifiedBy: string;
@IsOptional()
@IsString()
notes?: string;
}
export class CompleteDispensationDto {
@IsOptional()
@IsString()
@MaxLength(200)
receivedBy?: string;
@IsOptional()
@IsString()
@MaxLength(100)
receiverRelationship?: string;
@IsOptional()
@IsString()
@MaxLength(100)
receiverIdentification?: string;
@IsOptional()
@IsBoolean()
signatureCaptured?: boolean;
@IsOptional()
@IsBoolean()
counselingProvided?: boolean;
@IsOptional()
@IsString()
counselingNotes?: string;
}
export class CancelDispensationDto {
@IsString()
@MaxLength(500)
reason: string;
}
export class ReturnDispensationDto {
@IsInt()
@Min(1)
quantity: number;
@IsString()
@MaxLength(500)
reason: string;
}
export class DispensationQueryDto {
@IsOptional()
@IsUUID()
prescriptionId?: string;
@IsOptional()
@IsUUID()
patientId?: string;
@IsOptional()
@IsUUID()
medicationId?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsEnum(['pending', 'processing', 'completed', 'cancelled', 'returned'])
status?: DispensationStatus;
@IsOptional()
@IsEnum(['full', 'partial', 'refill'])
type?: DispensationType;
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}

View File

@ -0,0 +1,139 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { PharmacyPrescription } from './prescription.entity';
export type DispensationStatus = 'pending' | 'processing' | 'completed' | 'cancelled' | 'returned';
export type DispensationType = 'full' | 'partial' | 'refill';
@Entity({ name: 'dispensations', schema: 'clinica' })
export class Dispensation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'dispensation_number', type: 'varchar', length: 50 })
dispensationNumber: string;
@Index()
@Column({ name: 'prescription_id', type: 'uuid' })
prescriptionId: string;
@ManyToOne(() => PharmacyPrescription, (prescription) => prescription.dispensations)
@JoinColumn({ name: 'prescription_id' })
prescription: PharmacyPrescription;
@Index()
@Column({ name: 'patient_id', type: 'uuid' })
patientId: string;
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
inventoryId?: string;
@Column({ name: 'medication_id', type: 'uuid' })
medicationId: string;
@Column({ name: 'medication_name', type: 'varchar', length: 200 })
medicationName: string;
@Column({ name: 'batch_number', type: 'varchar', length: 100, nullable: true })
batchNumber?: string;
@Column({ name: 'lot_number', type: 'varchar', length: 100, nullable: true })
lotNumber?: string;
@Column({ name: 'quantity_requested', type: 'int' })
quantityRequested: number;
@Column({ name: 'quantity_dispensed', type: 'int' })
quantityDispensed: number;
@Column({ type: 'enum', enum: ['full', 'partial', 'refill'], default: 'full' })
type: DispensationType;
@Column({ type: 'enum', enum: ['pending', 'processing', 'completed', 'cancelled', 'returned'], default: 'pending' })
status: DispensationStatus;
@Column({ name: 'dispensed_by', type: 'uuid', nullable: true })
dispensedBy?: string;
@Column({ name: 'dispensed_at', type: 'timestamptz', nullable: true })
dispensedAt?: Date;
@Column({ name: 'verified_by', type: 'uuid', nullable: true })
verifiedBy?: string;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt?: Date;
@Column({ name: 'received_by', type: 'varchar', length: 200, nullable: true })
receivedBy?: string;
@Column({ name: 'receiver_relationship', type: 'varchar', length: 100, nullable: true })
receiverRelationship?: string;
@Column({ name: 'receiver_identification', type: 'varchar', length: 100, nullable: true })
receiverIdentification?: string;
@Column({ name: 'signature_captured', type: 'boolean', default: false })
signatureCaptured: boolean;
@Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
unitPrice?: number;
@Column({ name: 'total_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
totalPrice?: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'insurance_covered', type: 'decimal', precision: 10, scale: 2, default: 0 })
insuranceCovered: number;
@Column({ name: 'patient_copay', type: 'decimal', precision: 10, scale: 2, nullable: true })
patientCopay?: number;
@Column({ type: 'text', nullable: true })
instructions?: string;
@Column({ name: 'counseling_provided', type: 'boolean', default: false })
counselingProvided: boolean;
@Column({ name: 'counseling_notes', type: 'text', nullable: true })
counselingNotes?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date;
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
cancellationReason?: string;
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
returnedAt?: Date;
@Column({ name: 'return_reason', type: 'text', nullable: true })
returnReason?: string;
@Column({ name: 'return_quantity', type: 'int', nullable: true })
returnQuantity?: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,22 @@
export {
Medication,
MedicationCategory,
MedicationStatus,
MedicationForm,
ControlledSubstanceSchedule,
} from './medication.entity';
export {
PharmacyInventory,
InventoryStatus,
StockMovement,
} from './pharmacy-inventory.entity';
export {
PharmacyPrescription,
PharmacyPrescriptionStatus,
PrescriptionPriority,
} from './prescription.entity';
export {
Dispensation,
DispensationStatus,
DispensationType,
} from './dispensation.entity';

View File

@ -0,0 +1,97 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
export type MedicationCategory = 'antibiotic' | 'analgesic' | 'anti_inflammatory' | 'antihypertensive' | 'antidiabetic' | 'psychotropic' | 'vitamin' | 'vaccine' | 'other';
export type MedicationStatus = 'active' | 'inactive' | 'discontinued';
export type MedicationForm = 'tablet' | 'capsule' | 'syrup' | 'injection' | 'cream' | 'ointment' | 'drops' | 'inhaler' | 'patch' | 'suppository' | 'other';
export type ControlledSubstanceSchedule = 'none' | 'schedule_i' | 'schedule_ii' | 'schedule_iii' | 'schedule_iv' | 'schedule_v';
@Entity({ name: 'medications', schema: 'clinica' })
export class Medication {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ name: 'generic_name', type: 'varchar', length: 200, nullable: true })
genericName?: string;
@Column({ name: 'brand_name', type: 'varchar', length: 200, nullable: true })
brandName?: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ type: 'enum', enum: ['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'], default: 'other' })
category: MedicationCategory;
@Column({ type: 'enum', enum: ['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'], default: 'tablet' })
form: MedicationForm;
@Column({ type: 'varchar', length: 100, nullable: true })
strength?: string;
@Column({ type: 'varchar', length: 50, nullable: true })
unit?: string;
@Column({ name: 'active_ingredient', type: 'varchar', length: 300, nullable: true })
activeIngredient?: string;
@Column({ type: 'varchar', length: 200, nullable: true })
manufacturer?: string;
@Column({ name: 'is_controlled_substance', type: 'boolean', default: false })
isControlledSubstance: boolean;
@Column({ name: 'controlled_schedule', type: 'enum', enum: ['none', 'schedule_i', 'schedule_ii', 'schedule_iii', 'schedule_iv', 'schedule_v'], default: 'none' })
controlledSchedule: ControlledSubstanceSchedule;
@Column({ name: 'requires_prescription', type: 'boolean', default: true })
requiresPrescription: boolean;
@Column({ name: 'storage_conditions', type: 'text', nullable: true })
storageConditions?: string;
@Column({ type: 'text', nullable: true })
contraindications?: string;
@Column({ name: 'side_effects', type: 'text', nullable: true })
sideEffects?: string;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
price?: number;
@Column({ name: 'cost_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
costPrice?: number;
@Column({ type: 'enum', enum: ['active', 'inactive', 'discontinued'], default: 'active' })
status: MedicationStatus;
@Column({ name: 'display_order', type: 'int', default: 0 })
displayOrder: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,103 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Medication } from './medication.entity';
export type InventoryStatus = 'in_stock' | 'low_stock' | 'out_of_stock' | 'expired';
export interface StockMovement {
date: Date;
type: 'in' | 'out' | 'adjustment';
quantity: number;
reason: string;
reference?: string;
performedBy: string;
}
@Entity({ name: 'pharmacy_inventory', schema: 'clinica' })
export class PharmacyInventory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'medication_id', type: 'uuid' })
medicationId: string;
@ManyToOne(() => Medication)
@JoinColumn({ name: 'medication_id' })
medication: Medication;
@Column({ name: 'batch_number', type: 'varchar', length: 100, nullable: true })
batchNumber?: string;
@Column({ name: 'lot_number', type: 'varchar', length: 100, nullable: true })
lotNumber?: string;
@Column({ name: 'quantity_on_hand', type: 'int', default: 0 })
quantityOnHand: number;
@Column({ name: 'quantity_reserved', type: 'int', default: 0 })
quantityReserved: number;
@Column({ name: 'quantity_available', type: 'int', default: 0 })
quantityAvailable: number;
@Column({ name: 'reorder_level', type: 'int', default: 10 })
reorderLevel: number;
@Column({ name: 'reorder_quantity', type: 'int', default: 50 })
reorderQuantity: number;
@Column({ name: 'maximum_stock', type: 'int', nullable: true })
maximumStock?: number;
@Column({ name: 'unit_of_measure', type: 'varchar', length: 50, default: 'unit' })
unitOfMeasure: string;
@Column({ name: 'cost_per_unit', type: 'decimal', precision: 10, scale: 2, nullable: true })
costPerUnit?: number;
@Column({ name: 'expiration_date', type: 'date', nullable: true })
expirationDate?: Date;
@Column({ name: 'manufacture_date', type: 'date', nullable: true })
manufactureDate?: Date;
@Column({ type: 'varchar', length: 200, nullable: true })
location?: string;
@Column({ name: 'shelf_location', type: 'varchar', length: 100, nullable: true })
shelfLocation?: string;
@Column({ type: 'enum', enum: ['in_stock', 'low_stock', 'out_of_stock', 'expired'], default: 'in_stock' })
status: InventoryStatus;
@Column({ name: 'last_restock_date', type: 'timestamptz', nullable: true })
lastRestockDate?: Date;
@Column({ name: 'last_dispensed_date', type: 'timestamptz', nullable: true })
lastDispensedDate?: Date;
@Column({ name: 'stock_movements', type: 'jsonb', nullable: true })
stockMovements?: StockMovement[];
@Column({ type: 'text', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,114 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { Dispensation } from './dispensation.entity';
export type PharmacyPrescriptionStatus = 'pending' | 'partially_filled' | 'filled' | 'cancelled' | 'expired';
export type PrescriptionPriority = 'routine' | 'urgent' | 'stat';
@Entity({ name: 'pharmacy_prescriptions', schema: 'clinica' })
export class PharmacyPrescription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'prescription_number', type: 'varchar', length: 50 })
prescriptionNumber: string;
@Index()
@Column({ name: 'patient_id', type: 'uuid' })
patientId: string;
@Column({ name: 'consultation_id', type: 'uuid', nullable: true })
consultationId?: string;
@Column({ name: 'original_prescription_id', type: 'uuid', nullable: true })
originalPrescriptionId?: string;
@Index()
@Column({ name: 'prescribing_doctor_id', type: 'uuid' })
prescribingDoctorId: string;
@Column({ name: 'prescription_date', type: 'timestamptz', default: () => 'NOW()' })
prescriptionDate: Date;
@Column({ name: 'expiration_date', type: 'date', nullable: true })
expirationDate?: Date;
@Index()
@Column({ name: 'medication_id', type: 'uuid' })
medicationId: string;
@Column({ name: 'medication_name', type: 'varchar', length: 200 })
medicationName: string;
@Column({ name: 'medication_code', type: 'varchar', length: 50, nullable: true })
medicationCode?: string;
@Column({ name: 'quantity_prescribed', type: 'int' })
quantityPrescribed: number;
@Column({ name: 'quantity_dispensed', type: 'int', default: 0 })
quantityDispensed: number;
@Column({ name: 'quantity_remaining', type: 'int' })
quantityRemaining: number;
@Column({ type: 'varchar', length: 100 })
dosage: string;
@Column({ type: 'varchar', length: 100 })
frequency: string;
@Column({ type: 'varchar', length: 100, nullable: true })
duration?: string;
@Column({ name: 'route_of_administration', type: 'varchar', length: 100, nullable: true })
routeOfAdministration?: string;
@Column({ type: 'text', nullable: true })
instructions?: string;
@Column({ name: 'refills_allowed', type: 'int', default: 0 })
refillsAllowed: number;
@Column({ name: 'refills_remaining', type: 'int', default: 0 })
refillsRemaining: number;
@Column({ name: 'is_controlled_substance', type: 'boolean', default: false })
isControlledSubstance: boolean;
@Column({ type: 'enum', enum: ['routine', 'urgent', 'stat'], default: 'routine' })
priority: PrescriptionPriority;
@Column({ type: 'enum', enum: ['pending', 'partially_filled', 'filled', 'cancelled', 'expired'], default: 'pending' })
status: PharmacyPrescriptionStatus;
@Column({ name: 'clinical_notes', type: 'text', nullable: true })
clinicalNotes?: string;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date;
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
cancellationReason?: string;
@OneToMany(() => Dispensation, (dispensation) => dispensation.prescription)
dispensations?: Dispensation[];
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,44 @@
export { PharmacyModule, PharmacyModuleOptions } from './pharmacy.module';
export {
Medication,
MedicationCategory,
MedicationStatus,
MedicationForm,
ControlledSubstanceSchedule,
PharmacyInventory,
InventoryStatus,
StockMovement,
PharmacyPrescription,
PharmacyPrescriptionStatus,
PrescriptionPriority,
Dispensation,
DispensationStatus,
DispensationType,
} from './entities';
export {
MedicationService,
PharmacyInventoryService,
PrescriptionService,
DispensationService,
} from './services';
export { PharmacyController } from './controllers';
export {
CreateMedicationDto,
UpdateMedicationDto,
MedicationQueryDto,
CreateInventoryDto,
UpdateInventoryDto,
AdjustInventoryDto,
RestockInventoryDto,
InventoryQueryDto,
CreatePrescriptionDto,
UpdatePrescriptionDto,
CancelPrescriptionDto,
PrescriptionQueryDto,
CreateDispensationDto,
VerifyDispensationDto,
CompleteDispensationDto,
CancelDispensationDto,
ReturnDispensationDto,
DispensationQueryDto,
} from './dto';

View File

@ -0,0 +1,19 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { PharmacyController } from './controllers';
export interface PharmacyModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class PharmacyModule {
public router: Router;
private controller: PharmacyController;
constructor(options: PharmacyModuleOptions) {
const { dataSource, basePath = '/api' } = options;
this.controller = new PharmacyController(dataSource, basePath);
this.router = this.controller.router;
}
}

View File

@ -0,0 +1,377 @@
import { DataSource, Repository } from 'typeorm';
import { Dispensation, PharmacyPrescription, PharmacyInventory } from '../entities';
import {
CreateDispensationDto,
VerifyDispensationDto,
CompleteDispensationDto,
CancelDispensationDto,
ReturnDispensationDto,
DispensationQueryDto,
} from '../dto';
import { PrescriptionService } from './prescription.service';
import { PharmacyInventoryService } from './pharmacy-inventory.service';
export class DispensationService {
private repository: Repository<Dispensation>;
private prescriptionRepository: Repository<PharmacyPrescription>;
private inventoryRepository: Repository<PharmacyInventory>;
private prescriptionService: PrescriptionService;
private inventoryService: PharmacyInventoryService;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Dispensation);
this.prescriptionRepository = dataSource.getRepository(PharmacyPrescription);
this.inventoryRepository = dataSource.getRepository(PharmacyInventory);
this.prescriptionService = new PrescriptionService(dataSource);
this.inventoryService = new PharmacyInventoryService(dataSource);
}
async findAll(tenantId: string, query: DispensationQueryDto): Promise<{ data: Dispensation[]; total: number }> {
const {
prescriptionId,
patientId,
medicationId,
dateFrom,
dateTo,
status,
type,
page = 1,
limit = 20,
} = query;
const queryBuilder = this.repository.createQueryBuilder('disp')
.leftJoinAndSelect('disp.prescription', 'rx')
.where('disp.tenant_id = :tenantId', { tenantId });
if (prescriptionId) {
queryBuilder.andWhere('disp.prescription_id = :prescriptionId', { prescriptionId });
}
if (patientId) {
queryBuilder.andWhere('disp.patient_id = :patientId', { patientId });
}
if (medicationId) {
queryBuilder.andWhere('disp.medication_id = :medicationId', { medicationId });
}
if (dateFrom) {
queryBuilder.andWhere('disp.created_at >= :dateFrom', { dateFrom });
}
if (dateTo) {
queryBuilder.andWhere('disp.created_at <= :dateTo', { dateTo });
}
if (status) {
queryBuilder.andWhere('disp.status = :status', { status });
}
if (type) {
queryBuilder.andWhere('disp.type = :type', { type });
}
queryBuilder
.orderBy('disp.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async findById(tenantId: string, id: string): Promise<Dispensation | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['prescription'],
});
}
async findByDispensationNumber(tenantId: string, dispensationNumber: string): Promise<Dispensation | null> {
return this.repository.findOne({
where: { dispensationNumber, tenantId },
relations: ['prescription'],
});
}
async findByPrescription(tenantId: string, prescriptionId: string): Promise<Dispensation[]> {
return this.repository.find({
where: { tenantId, prescriptionId },
order: { createdAt: 'DESC' },
});
}
async findByPatient(tenantId: string, patientId: string, limit: number = 20): Promise<Dispensation[]> {
return this.repository.find({
where: { tenantId, patientId },
relations: ['prescription'],
order: { createdAt: 'DESC' },
take: limit,
});
}
async findPendingDispensations(tenantId: string): Promise<Dispensation[]> {
return this.repository.find({
where: { tenantId, status: 'pending' },
relations: ['prescription'],
order: { createdAt: 'ASC' },
});
}
async create(tenantId: string, dto: CreateDispensationDto): Promise<Dispensation> {
const prescription = await this.prescriptionRepository.findOne({
where: { id: dto.prescriptionId, tenantId },
});
if (!prescription) {
throw new Error('Prescription not found');
}
if (prescription.status === 'cancelled' || prescription.status === 'expired') {
throw new Error('Cannot dispense cancelled or expired prescription');
}
if (prescription.status === 'filled' && prescription.refillsRemaining <= 0) {
throw new Error('Prescription fully filled with no refills remaining');
}
if (dto.quantityDispensed > prescription.quantityRemaining) {
throw new Error(`Cannot dispense more than remaining quantity (${prescription.quantityRemaining})`);
}
const dispensationNumber = await this.generateDispensationNumber(tenantId);
let inventory: PharmacyInventory | null = null;
if (dto.inventoryId) {
inventory = await this.inventoryRepository.findOne({
where: { id: dto.inventoryId, tenantId },
});
if (!inventory) {
throw new Error('Inventory item not found');
}
if (inventory.quantityAvailable < dto.quantityDispensed) {
throw new Error('Insufficient inventory');
}
}
const type = dto.type || (
prescription.quantityDispensed === 0
? (dto.quantityDispensed < prescription.quantityPrescribed ? 'partial' : 'full')
: 'refill'
);
const totalPrice = dto.unitPrice
? dto.unitPrice * dto.quantityDispensed - (dto.discountAmount || 0)
: undefined;
const patientCopay = totalPrice !== undefined
? totalPrice - (dto.insuranceCovered || 0)
: undefined;
const dispensation = this.repository.create({
...dto,
tenantId,
dispensationNumber,
patientId: prescription.patientId,
medicationId: prescription.medicationId,
medicationName: prescription.medicationName,
batchNumber: inventory?.batchNumber,
lotNumber: inventory?.lotNumber,
type,
totalPrice,
patientCopay,
status: 'pending',
});
const saved = await this.repository.save(dispensation);
if (inventory) {
await this.inventoryService.reserveStock(tenantId, inventory.id, dto.quantityDispensed, dispensationNumber);
}
return this.findById(tenantId, saved.id) as Promise<Dispensation>;
}
async verify(tenantId: string, id: string, dto: VerifyDispensationDto): Promise<Dispensation | null> {
const dispensation = await this.findById(tenantId, id);
if (!dispensation) return null;
if (dispensation.status !== 'pending') {
throw new Error('Can only verify pending dispensations');
}
dispensation.status = 'processing';
dispensation.verifiedBy = dto.verifiedBy;
dispensation.verifiedAt = new Date();
if (dto.notes) {
dispensation.notes = dispensation.notes
? `${dispensation.notes}\n\nVerification note: ${dto.notes}`
: `Verification note: ${dto.notes}`;
}
return this.repository.save(dispensation);
}
async complete(tenantId: string, id: string, dto: CompleteDispensationDto): Promise<Dispensation | null> {
const dispensation = await this.findById(tenantId, id);
if (!dispensation) return null;
if (dispensation.status !== 'processing' && dispensation.status !== 'pending') {
throw new Error('Can only complete pending or processing dispensations');
}
dispensation.status = 'completed';
dispensation.dispensedAt = new Date();
if (dto.receivedBy) dispensation.receivedBy = dto.receivedBy;
if (dto.receiverRelationship) dispensation.receiverRelationship = dto.receiverRelationship;
if (dto.receiverIdentification) dispensation.receiverIdentification = dto.receiverIdentification;
if (dto.signatureCaptured !== undefined) dispensation.signatureCaptured = dto.signatureCaptured;
if (dto.counselingProvided !== undefined) dispensation.counselingProvided = dto.counselingProvided;
if (dto.counselingNotes) dispensation.counselingNotes = dto.counselingNotes;
const saved = await this.repository.save(dispensation);
await this.prescriptionService.updateDispensedQuantity(
tenantId,
dispensation.prescriptionId,
dispensation.quantityDispensed
);
if (dispensation.inventoryId) {
await this.inventoryService.dispenseStock(
tenantId,
dispensation.inventoryId,
dispensation.quantityDispensed,
dispensation.dispensationNumber,
dispensation.dispensedBy
);
}
return this.findById(tenantId, saved.id);
}
async cancel(tenantId: string, id: string, dto: CancelDispensationDto): Promise<Dispensation | null> {
const dispensation = await this.findById(tenantId, id);
if (!dispensation) return null;
if (dispensation.status === 'completed') {
throw new Error('Cannot cancel completed dispensation. Use return instead.');
}
dispensation.status = 'cancelled';
dispensation.cancelledAt = new Date();
dispensation.cancellationReason = dto.reason;
const saved = await this.repository.save(dispensation);
if (dispensation.inventoryId) {
await this.inventoryService.releaseReservation(
tenantId,
dispensation.inventoryId,
dispensation.quantityDispensed
);
}
return saved;
}
async processReturn(tenantId: string, id: string, dto: ReturnDispensationDto): Promise<Dispensation | null> {
const dispensation = await this.findById(tenantId, id);
if (!dispensation) return null;
if (dispensation.status !== 'completed') {
throw new Error('Can only return completed dispensations');
}
if (dto.quantity > dispensation.quantityDispensed) {
throw new Error('Return quantity cannot exceed dispensed quantity');
}
dispensation.status = 'returned';
dispensation.returnedAt = new Date();
dispensation.returnReason = dto.reason;
dispensation.returnQuantity = dto.quantity;
const saved = await this.repository.save(dispensation);
const prescription = await this.prescriptionRepository.findOne({
where: { id: dispensation.prescriptionId, tenantId },
});
if (prescription) {
prescription.quantityDispensed -= dto.quantity;
prescription.quantityRemaining += dto.quantity;
if (prescription.status === 'filled') {
prescription.status = 'partially_filled';
}
await this.prescriptionRepository.save(prescription);
}
return saved;
}
async getDispensationStats(tenantId: string): Promise<{
pendingCount: number;
processingCount: number;
completedTodayCount: number;
totalDispensedToday: number;
}> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [pendingCount, processingCount, completedToday] = await Promise.all([
this.repository.count({ where: { tenantId, status: 'pending' } }),
this.repository.count({ where: { tenantId, status: 'processing' } }),
this.repository.createQueryBuilder('disp')
.select('COUNT(*)', 'count')
.addSelect('COALESCE(SUM(disp.quantity_dispensed), 0)', 'total')
.where('disp.tenant_id = :tenantId', { tenantId })
.andWhere('disp.status = :status', { status: 'completed' })
.andWhere('disp.dispensed_at >= :today', { today })
.getRawOne(),
]);
return {
pendingCount,
processingCount,
completedTodayCount: parseInt(completedToday?.count || '0', 10),
totalDispensedToday: parseInt(completedToday?.total || '0', 10),
};
}
async getPatientDispensationHistory(tenantId: string, patientId: string, medicationId?: string): Promise<Dispensation[]> {
const queryBuilder = this.repository.createQueryBuilder('disp')
.leftJoinAndSelect('disp.prescription', 'rx')
.where('disp.tenant_id = :tenantId', { tenantId })
.andWhere('disp.patient_id = :patientId', { patientId })
.andWhere('disp.status = :status', { status: 'completed' });
if (medicationId) {
queryBuilder.andWhere('disp.medication_id = :medicationId', { medicationId });
}
return queryBuilder
.orderBy('disp.dispensed_at', 'DESC')
.take(50)
.getMany();
}
private async generateDispensationNumber(tenantId: string): Promise<string> {
const today = new Date();
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
const count = await this.repository.createQueryBuilder('disp')
.where('disp.tenant_id = :tenantId', { tenantId })
.andWhere('DATE(disp.created_at) = CURRENT_DATE')
.getCount();
const sequence = (count + 1).toString().padStart(4, '0');
return `DISP-${dateStr}-${sequence}`;
}
}

View File

@ -0,0 +1,4 @@
export { MedicationService } from './medication.service';
export { PharmacyInventoryService } from './pharmacy-inventory.service';
export { PrescriptionService } from './prescription.service';
export { DispensationService } from './dispensation.service';

View File

@ -0,0 +1,144 @@
import { DataSource, Repository } from 'typeorm';
import { Medication } from '../entities';
import { CreateMedicationDto, UpdateMedicationDto, MedicationQueryDto } from '../dto';
export class MedicationService {
private repository: Repository<Medication>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(Medication);
}
async findAll(tenantId: string, query: MedicationQueryDto): Promise<{ data: Medication[]; total: number }> {
const { search, category, form, status, isControlledSubstance, requiresPrescription, page = 1, limit = 50 } = query;
const queryBuilder = this.repository.createQueryBuilder('med')
.where('med.tenant_id = :tenantId', { tenantId })
.andWhere('med.deleted_at IS NULL');
if (category) {
queryBuilder.andWhere('med.category = :category', { category });
}
if (form) {
queryBuilder.andWhere('med.form = :form', { form });
}
if (status) {
queryBuilder.andWhere('med.status = :status', { status });
}
if (isControlledSubstance !== undefined) {
queryBuilder.andWhere('med.is_controlled_substance = :isControlledSubstance', { isControlledSubstance });
}
if (requiresPrescription !== undefined) {
queryBuilder.andWhere('med.requires_prescription = :requiresPrescription', { requiresPrescription });
}
if (search) {
queryBuilder.andWhere(
'(med.code ILIKE :search OR med.name ILIKE :search OR med.generic_name ILIKE :search OR med.brand_name ILIKE :search)',
{ search: `%${search}%` }
);
}
queryBuilder
.orderBy('med.category', 'ASC')
.addOrderBy('med.display_order', 'ASC')
.addOrderBy('med.name', 'ASC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async findById(tenantId: string, id: string): Promise<Medication | null> {
return this.repository.findOne({
where: { id, tenantId },
});
}
async findByCode(tenantId: string, code: string): Promise<Medication | null> {
return this.repository.findOne({
where: { code, tenantId },
});
}
async findByIds(tenantId: string, ids: string[]): Promise<Medication[]> {
if (ids.length === 0) return [];
return this.repository.createQueryBuilder('med')
.where('med.tenant_id = :tenantId', { tenantId })
.andWhere('med.id IN (:...ids)', { ids })
.andWhere('med.deleted_at IS NULL')
.getMany();
}
async findActiveByCategory(tenantId: string, category: string): Promise<Medication[]> {
return this.repository.find({
where: { tenantId, category: category as any, status: 'active' },
order: { displayOrder: 'ASC', name: 'ASC' },
});
}
async findControlledSubstances(tenantId: string): Promise<Medication[]> {
return this.repository.find({
where: { tenantId, isControlledSubstance: true, status: 'active' },
order: { controlledSchedule: 'ASC', name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateMedicationDto): Promise<Medication> {
const medication = this.repository.create({
...dto,
tenantId,
});
return this.repository.save(medication);
}
async update(tenantId: string, id: string, dto: UpdateMedicationDto): Promise<Medication | null> {
const medication = await this.findById(tenantId, id);
if (!medication) return null;
Object.assign(medication, dto);
return this.repository.save(medication);
}
async softDelete(tenantId: string, id: string): Promise<boolean> {
const medication = await this.findById(tenantId, id);
if (!medication) return false;
await this.repository.softDelete(id);
return true;
}
async getCategories(tenantId: string): Promise<{ category: string; count: number }[]> {
const result = await this.repository.createQueryBuilder('med')
.select('med.category', 'category')
.addSelect('COUNT(*)', 'count')
.where('med.tenant_id = :tenantId', { tenantId })
.andWhere('med.deleted_at IS NULL')
.andWhere('med.status = :status', { status: 'active' })
.groupBy('med.category')
.orderBy('med.category', 'ASC')
.getRawMany();
return result.map(r => ({ category: r.category, count: parseInt(r.count, 10) }));
}
async searchByName(tenantId: string, searchTerm: string, limit: number = 20): Promise<Medication[]> {
return this.repository.createQueryBuilder('med')
.where('med.tenant_id = :tenantId', { tenantId })
.andWhere('med.deleted_at IS NULL')
.andWhere('med.status = :status', { status: 'active' })
.andWhere(
'(med.name ILIKE :search OR med.generic_name ILIKE :search OR med.brand_name ILIKE :search)',
{ search: `%${searchTerm}%` }
)
.orderBy('med.name', 'ASC')
.take(limit)
.getMany();
}
}

View File

@ -0,0 +1,374 @@
import { DataSource, Repository, LessThanOrEqual, MoreThan } from 'typeorm';
import { PharmacyInventory, Medication, InventoryStatus, StockMovement } from '../entities';
import {
CreateInventoryDto,
UpdateInventoryDto,
AdjustInventoryDto,
RestockInventoryDto,
InventoryQueryDto,
} from '../dto';
export class PharmacyInventoryService {
private repository: Repository<PharmacyInventory>;
private medicationRepository: Repository<Medication>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(PharmacyInventory);
this.medicationRepository = dataSource.getRepository(Medication);
}
async findAll(tenantId: string, query: InventoryQueryDto): Promise<{ data: PharmacyInventory[]; total: number }> {
const { medicationId, status, lowStockOnly, expiringOnly, expiringWithinDays, location, page = 1, limit = 50 } = query;
const queryBuilder = this.repository.createQueryBuilder('inv')
.leftJoinAndSelect('inv.medication', 'med')
.where('inv.tenant_id = :tenantId', { tenantId });
if (medicationId) {
queryBuilder.andWhere('inv.medication_id = :medicationId', { medicationId });
}
if (status) {
queryBuilder.andWhere('inv.status = :status', { status });
}
if (lowStockOnly) {
queryBuilder.andWhere('inv.quantity_available <= inv.reorder_level');
}
if (expiringOnly || expiringWithinDays) {
const daysToCheck = expiringWithinDays || 30;
const expirationThreshold = new Date();
expirationThreshold.setDate(expirationThreshold.getDate() + daysToCheck);
queryBuilder.andWhere('inv.expiration_date <= :expirationThreshold', { expirationThreshold });
queryBuilder.andWhere('inv.expiration_date >= CURRENT_DATE');
}
if (location) {
queryBuilder.andWhere('inv.location ILIKE :location', { location: `%${location}%` });
}
queryBuilder
.orderBy('inv.status', 'ASC')
.addOrderBy('med.name', 'ASC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async findById(tenantId: string, id: string): Promise<PharmacyInventory | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['medication'],
});
}
async findByMedication(tenantId: string, medicationId: string): Promise<PharmacyInventory[]> {
return this.repository.find({
where: { tenantId, medicationId },
relations: ['medication'],
order: { expirationDate: 'ASC' },
});
}
async findAvailableByMedication(tenantId: string, medicationId: string): Promise<PharmacyInventory[]> {
return this.repository.createQueryBuilder('inv')
.leftJoinAndSelect('inv.medication', 'med')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.medication_id = :medicationId', { medicationId })
.andWhere('inv.quantity_available > 0')
.andWhere('inv.status != :expired', { expired: 'expired' })
.andWhere('(inv.expiration_date IS NULL OR inv.expiration_date > CURRENT_DATE)')
.orderBy('inv.expiration_date', 'ASC')
.getMany();
}
async create(tenantId: string, dto: CreateInventoryDto): Promise<PharmacyInventory> {
const medication = await this.medicationRepository.findOne({
where: { id: dto.medicationId, tenantId },
});
if (!medication) {
throw new Error('Medication not found');
}
const quantityAvailable = dto.quantityOnHand;
const status = this.calculateStatus(quantityAvailable, dto.reorderLevel || 10, dto.expirationDate);
const inventory = this.repository.create({
...dto,
tenantId,
quantityAvailable,
quantityReserved: 0,
status,
lastRestockDate: new Date(),
stockMovements: [{
date: new Date(),
type: 'in',
quantity: dto.quantityOnHand,
reason: 'Initial stock',
performedBy: 'system',
}],
});
return this.repository.save(inventory);
}
async update(tenantId: string, id: string, dto: UpdateInventoryDto): Promise<PharmacyInventory | null> {
const inventory = await this.findById(tenantId, id);
if (!inventory) return null;
Object.assign(inventory, dto);
if (dto.expirationDate) {
inventory.status = this.calculateStatus(
inventory.quantityAvailable,
inventory.reorderLevel,
dto.expirationDate
);
}
return this.repository.save(inventory);
}
async adjustStock(tenantId: string, id: string, dto: AdjustInventoryDto): Promise<PharmacyInventory | null> {
const inventory = await this.findById(tenantId, id);
if (!inventory) return null;
const newQuantity = inventory.quantityOnHand + dto.adjustment;
if (newQuantity < 0) {
throw new Error('Adjustment would result in negative stock');
}
const movement: StockMovement = {
date: new Date(),
type: dto.adjustment > 0 ? 'in' : 'adjustment',
quantity: dto.adjustment,
reason: dto.reason,
reference: dto.reference,
performedBy: dto.performedBy || 'system',
};
inventory.quantityOnHand = newQuantity;
inventory.quantityAvailable = newQuantity - inventory.quantityReserved;
inventory.stockMovements = inventory.stockMovements || [];
inventory.stockMovements.push(movement);
inventory.status = this.calculateStatus(
inventory.quantityAvailable,
inventory.reorderLevel,
inventory.expirationDate?.toString()
);
return this.repository.save(inventory);
}
async restock(tenantId: string, id: string, dto: RestockInventoryDto): Promise<PharmacyInventory | null> {
const inventory = await this.findById(tenantId, id);
if (!inventory) return null;
const movement: StockMovement = {
date: new Date(),
type: 'in',
quantity: dto.quantity,
reason: 'Restock',
reference: dto.reference,
performedBy: dto.performedBy || 'system',
};
inventory.quantityOnHand += dto.quantity;
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
inventory.lastRestockDate = new Date();
inventory.stockMovements = inventory.stockMovements || [];
inventory.stockMovements.push(movement);
if (dto.batchNumber) inventory.batchNumber = dto.batchNumber;
if (dto.lotNumber) inventory.lotNumber = dto.lotNumber;
if (dto.expirationDate) inventory.expirationDate = new Date(dto.expirationDate);
if (dto.costPerUnit !== undefined) inventory.costPerUnit = dto.costPerUnit;
inventory.status = this.calculateStatus(
inventory.quantityAvailable,
inventory.reorderLevel,
inventory.expirationDate?.toString()
);
return this.repository.save(inventory);
}
async reserveStock(tenantId: string, id: string, quantity: number, reference: string): Promise<PharmacyInventory | null> {
const inventory = await this.findById(tenantId, id);
if (!inventory) return null;
if (inventory.quantityAvailable < quantity) {
throw new Error('Insufficient available stock');
}
inventory.quantityReserved += quantity;
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
inventory.status = this.calculateStatus(
inventory.quantityAvailable,
inventory.reorderLevel,
inventory.expirationDate?.toString()
);
return this.repository.save(inventory);
}
async releaseReservation(tenantId: string, id: string, quantity: number): Promise<PharmacyInventory | null> {
const inventory = await this.findById(tenantId, id);
if (!inventory) return null;
inventory.quantityReserved = Math.max(0, inventory.quantityReserved - quantity);
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
inventory.status = this.calculateStatus(
inventory.quantityAvailable,
inventory.reorderLevel,
inventory.expirationDate?.toString()
);
return this.repository.save(inventory);
}
async dispenseStock(tenantId: string, id: string, quantity: number, reference: string, performedBy?: string): Promise<PharmacyInventory | null> {
const inventory = await this.findById(tenantId, id);
if (!inventory) return null;
if (inventory.quantityOnHand < quantity) {
throw new Error('Insufficient stock');
}
const movement: StockMovement = {
date: new Date(),
type: 'out',
quantity: -quantity,
reason: 'Dispensed',
reference,
performedBy: performedBy || 'system',
};
inventory.quantityOnHand -= quantity;
inventory.quantityReserved = Math.max(0, inventory.quantityReserved - quantity);
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
inventory.lastDispensedDate = new Date();
inventory.stockMovements = inventory.stockMovements || [];
inventory.stockMovements.push(movement);
inventory.status = this.calculateStatus(
inventory.quantityAvailable,
inventory.reorderLevel,
inventory.expirationDate?.toString()
);
return this.repository.save(inventory);
}
async getLowStockAlerts(tenantId: string): Promise<PharmacyInventory[]> {
return this.repository.createQueryBuilder('inv')
.leftJoinAndSelect('inv.medication', 'med')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.quantity_available <= inv.reorder_level')
.andWhere('inv.status != :expired', { expired: 'expired' })
.orderBy('inv.quantity_available', 'ASC')
.getMany();
}
async getExpiringItems(tenantId: string, withinDays: number = 30): Promise<PharmacyInventory[]> {
const expirationThreshold = new Date();
expirationThreshold.setDate(expirationThreshold.getDate() + withinDays);
return this.repository.createQueryBuilder('inv')
.leftJoinAndSelect('inv.medication', 'med')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.expiration_date <= :expirationThreshold', { expirationThreshold })
.andWhere('inv.expiration_date >= CURRENT_DATE')
.andWhere('inv.quantity_on_hand > 0')
.orderBy('inv.expiration_date', 'ASC')
.getMany();
}
async getExpiredItems(tenantId: string): Promise<PharmacyInventory[]> {
return this.repository.createQueryBuilder('inv')
.leftJoinAndSelect('inv.medication', 'med')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.expiration_date < CURRENT_DATE')
.andWhere('inv.quantity_on_hand > 0')
.orderBy('inv.expiration_date', 'ASC')
.getMany();
}
async getInventorySummary(tenantId: string): Promise<{
totalItems: number;
lowStockCount: number;
outOfStockCount: number;
expiringCount: number;
expiredCount: number;
totalValue: number;
}> {
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
const [totalItems, lowStockCount, outOfStockCount, expiringCount, expiredCount, valueResult] = await Promise.all([
this.repository.count({ where: { tenantId } }),
this.repository.createQueryBuilder('inv')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.quantity_available <= inv.reorder_level')
.andWhere('inv.quantity_available > 0')
.getCount(),
this.repository.count({ where: { tenantId, status: 'out_of_stock' } }),
this.repository.createQueryBuilder('inv')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.expiration_date <= :threshold', { threshold: thirtyDaysFromNow })
.andWhere('inv.expiration_date >= CURRENT_DATE')
.getCount(),
this.repository.createQueryBuilder('inv')
.where('inv.tenant_id = :tenantId', { tenantId })
.andWhere('inv.expiration_date < CURRENT_DATE')
.getCount(),
this.repository.createQueryBuilder('inv')
.select('SUM(inv.quantity_on_hand * inv.cost_per_unit)', 'total')
.where('inv.tenant_id = :tenantId', { tenantId })
.getRawOne(),
]);
return {
totalItems,
lowStockCount,
outOfStockCount,
expiringCount,
expiredCount,
totalValue: parseFloat(valueResult?.total || '0'),
};
}
async updateExpiredStatus(tenantId: string): Promise<number> {
const result = await this.repository.createQueryBuilder()
.update(PharmacyInventory)
.set({ status: 'expired' })
.where('tenant_id = :tenantId', { tenantId })
.andWhere('expiration_date < CURRENT_DATE')
.andWhere('status != :expired', { expired: 'expired' })
.execute();
return result.affected || 0;
}
private calculateStatus(quantityAvailable: number, reorderLevel: number, expirationDate?: string): InventoryStatus {
if (expirationDate) {
const expDate = new Date(expirationDate);
if (expDate < new Date()) {
return 'expired';
}
}
if (quantityAvailable <= 0) {
return 'out_of_stock';
}
if (quantityAvailable <= reorderLevel) {
return 'low_stock';
}
return 'in_stock';
}
}

View File

@ -0,0 +1,310 @@
import { DataSource, Repository } from 'typeorm';
import { PharmacyPrescription, Medication, Dispensation } from '../entities';
import {
CreatePrescriptionDto,
UpdatePrescriptionDto,
CancelPrescriptionDto,
PrescriptionQueryDto,
} from '../dto';
export class PrescriptionService {
private repository: Repository<PharmacyPrescription>;
private medicationRepository: Repository<Medication>;
private dispensationRepository: Repository<Dispensation>;
constructor(dataSource: DataSource) {
this.repository = dataSource.getRepository(PharmacyPrescription);
this.medicationRepository = dataSource.getRepository(Medication);
this.dispensationRepository = dataSource.getRepository(Dispensation);
}
async findAll(tenantId: string, query: PrescriptionQueryDto): Promise<{ data: PharmacyPrescription[]; total: number }> {
const {
patientId,
doctorId,
consultationId,
medicationId,
dateFrom,
dateTo,
status,
priority,
isControlledSubstance,
page = 1,
limit = 20,
} = query;
const queryBuilder = this.repository.createQueryBuilder('rx')
.leftJoinAndSelect('rx.dispensations', 'disp')
.where('rx.tenant_id = :tenantId', { tenantId });
if (patientId) {
queryBuilder.andWhere('rx.patient_id = :patientId', { patientId });
}
if (doctorId) {
queryBuilder.andWhere('rx.prescribing_doctor_id = :doctorId', { doctorId });
}
if (consultationId) {
queryBuilder.andWhere('rx.consultation_id = :consultationId', { consultationId });
}
if (medicationId) {
queryBuilder.andWhere('rx.medication_id = :medicationId', { medicationId });
}
if (dateFrom) {
queryBuilder.andWhere('rx.prescription_date >= :dateFrom', { dateFrom });
}
if (dateTo) {
queryBuilder.andWhere('rx.prescription_date <= :dateTo', { dateTo });
}
if (status) {
queryBuilder.andWhere('rx.status = :status', { status });
}
if (priority) {
queryBuilder.andWhere('rx.priority = :priority', { priority });
}
if (isControlledSubstance !== undefined) {
queryBuilder.andWhere('rx.is_controlled_substance = :isControlledSubstance', { isControlledSubstance });
}
queryBuilder
.orderBy('rx.prescription_date', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return { data, total };
}
async findById(tenantId: string, id: string): Promise<PharmacyPrescription | null> {
return this.repository.findOne({
where: { id, tenantId },
relations: ['dispensations'],
});
}
async findByPrescriptionNumber(tenantId: string, prescriptionNumber: string): Promise<PharmacyPrescription | null> {
return this.repository.findOne({
where: { prescriptionNumber, tenantId },
relations: ['dispensations'],
});
}
async findByPatient(tenantId: string, patientId: string, limit: number = 20): Promise<PharmacyPrescription[]> {
return this.repository.find({
where: { tenantId, patientId },
relations: ['dispensations'],
order: { prescriptionDate: 'DESC' },
take: limit,
});
}
async findByConsultation(tenantId: string, consultationId: string): Promise<PharmacyPrescription[]> {
return this.repository.find({
where: { tenantId, consultationId },
relations: ['dispensations'],
order: { prescriptionDate: 'DESC' },
});
}
async findPendingPrescriptions(tenantId: string): Promise<PharmacyPrescription[]> {
return this.repository.find({
where: { tenantId, status: 'pending' },
relations: ['dispensations'],
order: { priority: 'DESC', prescriptionDate: 'ASC' },
});
}
async findControlledSubstancePrescriptions(tenantId: string, dateFrom?: string, dateTo?: string): Promise<PharmacyPrescription[]> {
const queryBuilder = this.repository.createQueryBuilder('rx')
.leftJoinAndSelect('rx.dispensations', 'disp')
.where('rx.tenant_id = :tenantId', { tenantId })
.andWhere('rx.is_controlled_substance = true');
if (dateFrom) {
queryBuilder.andWhere('rx.prescription_date >= :dateFrom', { dateFrom });
}
if (dateTo) {
queryBuilder.andWhere('rx.prescription_date <= :dateTo', { dateTo });
}
return queryBuilder
.orderBy('rx.prescription_date', 'DESC')
.getMany();
}
async create(tenantId: string, dto: CreatePrescriptionDto): Promise<PharmacyPrescription> {
const medication = await this.medicationRepository.findOne({
where: { id: dto.medicationId, tenantId },
});
if (!medication) {
throw new Error('Medication not found');
}
const prescriptionNumber = await this.generatePrescriptionNumber(tenantId);
const expirationDate = dto.expirationDate
? new Date(dto.expirationDate)
: this.calculateExpirationDate(dto.isControlledSubstance || medication.isControlledSubstance);
const prescription = this.repository.create({
...dto,
tenantId,
prescriptionNumber,
quantityRemaining: dto.quantityPrescribed,
quantityDispensed: 0,
refillsRemaining: dto.refillsAllowed || 0,
isControlledSubstance: dto.isControlledSubstance ?? medication.isControlledSubstance,
expirationDate,
status: 'pending',
});
return this.repository.save(prescription);
}
async update(tenantId: string, id: string, dto: UpdatePrescriptionDto): Promise<PharmacyPrescription | null> {
const prescription = await this.findById(tenantId, id);
if (!prescription) return null;
if (prescription.status === 'filled' || prescription.status === 'cancelled') {
throw new Error('Cannot update filled or cancelled prescription');
}
if (dto.quantityPrescribed !== undefined && dto.quantityPrescribed < prescription.quantityDispensed) {
throw new Error('New quantity cannot be less than quantity already dispensed');
}
Object.assign(prescription, dto);
if (dto.quantityPrescribed !== undefined) {
prescription.quantityRemaining = dto.quantityPrescribed - prescription.quantityDispensed;
}
if (dto.refillsAllowed !== undefined) {
const usedRefills = (prescription.refillsAllowed || 0) - prescription.refillsRemaining;
prescription.refillsRemaining = Math.max(0, dto.refillsAllowed - usedRefills);
}
return this.repository.save(prescription);
}
async cancel(tenantId: string, id: string, dto: CancelPrescriptionDto): Promise<PharmacyPrescription | null> {
const prescription = await this.findById(tenantId, id);
if (!prescription) return null;
if (prescription.status === 'filled') {
throw new Error('Cannot cancel filled prescription');
}
prescription.status = 'cancelled';
prescription.cancelledAt = new Date();
prescription.cancellationReason = dto.reason;
return this.repository.save(prescription);
}
async updateDispensedQuantity(tenantId: string, id: string, quantityDispensed: number): Promise<PharmacyPrescription | null> {
const prescription = await this.findById(tenantId, id);
if (!prescription) return null;
prescription.quantityDispensed += quantityDispensed;
prescription.quantityRemaining = prescription.quantityPrescribed - prescription.quantityDispensed;
if (prescription.quantityRemaining <= 0) {
prescription.status = 'filled';
} else if (prescription.quantityDispensed > 0) {
prescription.status = 'partially_filled';
}
return this.repository.save(prescription);
}
async useRefill(tenantId: string, id: string): Promise<PharmacyPrescription | null> {
const prescription = await this.findById(tenantId, id);
if (!prescription) return null;
if (prescription.refillsRemaining <= 0) {
throw new Error('No refills remaining');
}
prescription.refillsRemaining -= 1;
prescription.quantityRemaining = prescription.quantityPrescribed;
prescription.quantityDispensed = 0;
prescription.status = 'pending';
return this.repository.save(prescription);
}
async checkExpiredPrescriptions(tenantId: string): Promise<number> {
const result = await this.repository.createQueryBuilder()
.update(PharmacyPrescription)
.set({ status: 'expired' })
.where('tenant_id = :tenantId', { tenantId })
.andWhere('expiration_date < CURRENT_DATE')
.andWhere('status IN (:...statuses)', { statuses: ['pending', 'partially_filled'] })
.execute();
return result.affected || 0;
}
async getPrescriptionStats(tenantId: string): Promise<{
pendingCount: number;
partiallyFilledCount: number;
filledTodayCount: number;
controlledSubstanceCount: number;
}> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [pendingCount, partiallyFilledCount, filledTodayCount, controlledSubstanceCount] = await Promise.all([
this.repository.count({ where: { tenantId, status: 'pending' } }),
this.repository.count({ where: { tenantId, status: 'partially_filled' } }),
this.repository.createQueryBuilder('rx')
.where('rx.tenant_id = :tenantId', { tenantId })
.andWhere('rx.status = :status', { status: 'filled' })
.andWhere('rx.updated_at >= :today', { today })
.getCount(),
this.repository.count({
where: { tenantId, isControlledSubstance: true, status: 'pending' },
}),
]);
return {
pendingCount,
partiallyFilledCount,
filledTodayCount,
controlledSubstanceCount,
};
}
private async generatePrescriptionNumber(tenantId: string): Promise<string> {
const today = new Date();
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
const count = await this.repository.createQueryBuilder('rx')
.where('rx.tenant_id = :tenantId', { tenantId })
.andWhere('DATE(rx.created_at) = CURRENT_DATE')
.getCount();
const sequence = (count + 1).toString().padStart(4, '0');
return `RX-${dateStr}-${sequence}`;
}
private calculateExpirationDate(isControlledSubstance: boolean): Date {
const expirationDate = new Date();
if (isControlledSubstance) {
expirationDate.setDate(expirationDate.getDate() + 30);
} else {
expirationDate.setMonth(expirationDate.getMonth() + 6);
}
return expirationDate;
}
}