diff --git a/src/modules/pharmacy/controllers/index.ts b/src/modules/pharmacy/controllers/index.ts new file mode 100644 index 0000000..986ad67 --- /dev/null +++ b/src/modules/pharmacy/controllers/index.ts @@ -0,0 +1 @@ +export { PharmacyController } from './pharmacy.controller'; diff --git a/src/modules/pharmacy/controllers/pharmacy.controller.ts b/src/modules/pharmacy/controllers/pharmacy.controller.ts new file mode 100644 index 0000000..b31a026 --- /dev/null +++ b/src/modules/pharmacy/controllers/pharmacy.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/src/modules/pharmacy/dto/index.ts b/src/modules/pharmacy/dto/index.ts new file mode 100644 index 0000000..fcf416a --- /dev/null +++ b/src/modules/pharmacy/dto/index.ts @@ -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; +} diff --git a/src/modules/pharmacy/entities/dispensation.entity.ts b/src/modules/pharmacy/entities/dispensation.entity.ts new file mode 100644 index 0000000..c7b7a28 --- /dev/null +++ b/src/modules/pharmacy/entities/dispensation.entity.ts @@ -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; +} diff --git a/src/modules/pharmacy/entities/index.ts b/src/modules/pharmacy/entities/index.ts new file mode 100644 index 0000000..33ba529 --- /dev/null +++ b/src/modules/pharmacy/entities/index.ts @@ -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'; diff --git a/src/modules/pharmacy/entities/medication.entity.ts b/src/modules/pharmacy/entities/medication.entity.ts new file mode 100644 index 0000000..22c54a3 --- /dev/null +++ b/src/modules/pharmacy/entities/medication.entity.ts @@ -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; +} diff --git a/src/modules/pharmacy/entities/pharmacy-inventory.entity.ts b/src/modules/pharmacy/entities/pharmacy-inventory.entity.ts new file mode 100644 index 0000000..a16c402 --- /dev/null +++ b/src/modules/pharmacy/entities/pharmacy-inventory.entity.ts @@ -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; +} diff --git a/src/modules/pharmacy/entities/prescription.entity.ts b/src/modules/pharmacy/entities/prescription.entity.ts new file mode 100644 index 0000000..f292c51 --- /dev/null +++ b/src/modules/pharmacy/entities/prescription.entity.ts @@ -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; +} diff --git a/src/modules/pharmacy/index.ts b/src/modules/pharmacy/index.ts new file mode 100644 index 0000000..b31fbb5 --- /dev/null +++ b/src/modules/pharmacy/index.ts @@ -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'; diff --git a/src/modules/pharmacy/pharmacy.module.ts b/src/modules/pharmacy/pharmacy.module.ts new file mode 100644 index 0000000..1c63c01 --- /dev/null +++ b/src/modules/pharmacy/pharmacy.module.ts @@ -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; + } +} diff --git a/src/modules/pharmacy/services/dispensation.service.ts b/src/modules/pharmacy/services/dispensation.service.ts new file mode 100644 index 0000000..9ee9c03 --- /dev/null +++ b/src/modules/pharmacy/services/dispensation.service.ts @@ -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; + private prescriptionRepository: Repository; + private inventoryRepository: Repository; + 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 { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['prescription'], + }); + } + + async findByDispensationNumber(tenantId: string, dispensationNumber: string): Promise { + return this.repository.findOne({ + where: { dispensationNumber, tenantId }, + relations: ['prescription'], + }); + } + + async findByPrescription(tenantId: string, prescriptionId: string): Promise { + return this.repository.find({ + where: { tenantId, prescriptionId }, + order: { createdAt: 'DESC' }, + }); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 20): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['prescription'], + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + async findPendingDispensations(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'pending' }, + relations: ['prescription'], + order: { createdAt: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateDispensationDto): Promise { + 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; + } + + async verify(tenantId: string, id: string, dto: VerifyDispensationDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`; + } +} diff --git a/src/modules/pharmacy/services/index.ts b/src/modules/pharmacy/services/index.ts new file mode 100644 index 0000000..2e9df44 --- /dev/null +++ b/src/modules/pharmacy/services/index.ts @@ -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'; diff --git a/src/modules/pharmacy/services/medication.service.ts b/src/modules/pharmacy/services/medication.service.ts new file mode 100644 index 0000000..4a5b622 --- /dev/null +++ b/src/modules/pharmacy/services/medication.service.ts @@ -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; + + 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 { + return this.repository.findOne({ + where: { id, tenantId }, + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + async findByIds(tenantId: string, ids: string[]): Promise { + 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 { + return this.repository.find({ + where: { tenantId, category: category as any, status: 'active' }, + order: { displayOrder: 'ASC', name: 'ASC' }, + }); + } + + async findControlledSubstances(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, isControlledSubstance: true, status: 'active' }, + order: { controlledSchedule: 'ASC', name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateMedicationDto): Promise { + const medication = this.repository.create({ + ...dto, + tenantId, + }); + return this.repository.save(medication); + } + + async update(tenantId: string, id: string, dto: UpdateMedicationDto): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/src/modules/pharmacy/services/pharmacy-inventory.service.ts b/src/modules/pharmacy/services/pharmacy-inventory.service.ts new file mode 100644 index 0000000..200f67b --- /dev/null +++ b/src/modules/pharmacy/services/pharmacy-inventory.service.ts @@ -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; + private medicationRepository: Repository; + + 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 { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['medication'], + }); + } + + async findByMedication(tenantId: string, medicationId: string): Promise { + return this.repository.find({ + where: { tenantId, medicationId }, + relations: ['medication'], + order: { expirationDate: 'ASC' }, + }); + } + + async findAvailableByMedication(tenantId: string, medicationId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'; + } +} diff --git a/src/modules/pharmacy/services/prescription.service.ts b/src/modules/pharmacy/services/prescription.service.ts new file mode 100644 index 0000000..d3f609e --- /dev/null +++ b/src/modules/pharmacy/services/prescription.service.ts @@ -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; + private medicationRepository: Repository; + private dispensationRepository: Repository; + + 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 { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['dispensations'], + }); + } + + async findByPrescriptionNumber(tenantId: string, prescriptionNumber: string): Promise { + return this.repository.findOne({ + where: { prescriptionNumber, tenantId }, + relations: ['dispensations'], + }); + } + + async findByPatient(tenantId: string, patientId: string, limit: number = 20): Promise { + return this.repository.find({ + where: { tenantId, patientId }, + relations: ['dispensations'], + order: { prescriptionDate: 'DESC' }, + take: limit, + }); + } + + async findByConsultation(tenantId: string, consultationId: string): Promise { + return this.repository.find({ + where: { tenantId, consultationId }, + relations: ['dispensations'], + order: { prescriptionDate: 'DESC' }, + }); + } + + async findPendingPrescriptions(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: 'pending' }, + relations: ['dispensations'], + order: { priority: 'DESC', prescriptionDate: 'ASC' }, + }); + } + + async findControlledSubstancePrescriptions(tenantId: string, dateFrom?: string, dateTo?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +}