[CL-007] feat: Implement pharmacy module
Add complete pharmacy module for erp-clinicas with: Entities: - Medication: catalog with controlled substance flags, categories, forms - PharmacyInventory: stock management with batch/lot tracking, alerts - PharmacyPrescription: linked to consultation and patient - Dispensation: medication dispensing with partial fills support Services: - MedicationService: CRUD for medication catalog - PharmacyInventoryService: stock management, low stock alerts, expiry tracking - PrescriptionService: prescription workflow, refills, controlled substances - DispensationService: dispensing workflow, verification, returns Features: - Multi-tenant support (tenantId in all queries) - Controlled substance tracking and scheduling - Inventory with low stock and expiration alerts - Prescription linked to consultation and patient - Dispensation tracking with partial fills support - Stock reservation and dispensing workflow - FIFO dispensing based on expiration dates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
27ea1fd4a6
commit
50a409bf17
1
src/modules/pharmacy/controllers/index.ts
Normal file
1
src/modules/pharmacy/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { PharmacyController } from './pharmacy.controller';
|
||||
768
src/modules/pharmacy/controllers/pharmacy.controller.ts
Normal file
768
src/modules/pharmacy/controllers/pharmacy.controller.ts
Normal file
@ -0,0 +1,768 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
MedicationService,
|
||||
PharmacyInventoryService,
|
||||
PrescriptionService,
|
||||
DispensationService,
|
||||
} from '../services';
|
||||
import {
|
||||
CreateMedicationDto,
|
||||
UpdateMedicationDto,
|
||||
MedicationQueryDto,
|
||||
CreateInventoryDto,
|
||||
UpdateInventoryDto,
|
||||
AdjustInventoryDto,
|
||||
RestockInventoryDto,
|
||||
InventoryQueryDto,
|
||||
CreatePrescriptionDto,
|
||||
UpdatePrescriptionDto,
|
||||
CancelPrescriptionDto,
|
||||
PrescriptionQueryDto,
|
||||
CreateDispensationDto,
|
||||
VerifyDispensationDto,
|
||||
CompleteDispensationDto,
|
||||
CancelDispensationDto,
|
||||
ReturnDispensationDto,
|
||||
DispensationQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
export class PharmacyController {
|
||||
public router: Router;
|
||||
private medicationService: MedicationService;
|
||||
private inventoryService: PharmacyInventoryService;
|
||||
private prescriptionService: PrescriptionService;
|
||||
private dispensationService: DispensationService;
|
||||
|
||||
constructor(dataSource: DataSource, basePath: string = '/api') {
|
||||
this.router = Router();
|
||||
this.medicationService = new MedicationService(dataSource);
|
||||
this.inventoryService = new PharmacyInventoryService(dataSource);
|
||||
this.prescriptionService = new PrescriptionService(dataSource);
|
||||
this.dispensationService = new DispensationService(dataSource);
|
||||
this.setupRoutes(basePath);
|
||||
}
|
||||
|
||||
private setupRoutes(basePath: string): void {
|
||||
const pharmacyPath = `${basePath}/pharmacy`;
|
||||
|
||||
// Medication Routes
|
||||
this.router.get(`${pharmacyPath}/medications`, this.findAllMedications.bind(this));
|
||||
this.router.get(`${pharmacyPath}/medications/categories`, this.getMedicationCategories.bind(this));
|
||||
this.router.get(`${pharmacyPath}/medications/controlled`, this.getControlledSubstances.bind(this));
|
||||
this.router.get(`${pharmacyPath}/medications/search`, this.searchMedications.bind(this));
|
||||
this.router.get(`${pharmacyPath}/medications/:id`, this.findMedicationById.bind(this));
|
||||
this.router.post(`${pharmacyPath}/medications`, this.createMedication.bind(this));
|
||||
this.router.patch(`${pharmacyPath}/medications/:id`, this.updateMedication.bind(this));
|
||||
this.router.delete(`${pharmacyPath}/medications/:id`, this.deleteMedication.bind(this));
|
||||
|
||||
// Inventory Routes
|
||||
this.router.get(`${pharmacyPath}/inventory`, this.findAllInventory.bind(this));
|
||||
this.router.get(`${pharmacyPath}/inventory/summary`, this.getInventorySummary.bind(this));
|
||||
this.router.get(`${pharmacyPath}/inventory/low-stock`, this.getLowStockAlerts.bind(this));
|
||||
this.router.get(`${pharmacyPath}/inventory/expiring`, this.getExpiringItems.bind(this));
|
||||
this.router.get(`${pharmacyPath}/inventory/expired`, this.getExpiredItems.bind(this));
|
||||
this.router.get(`${pharmacyPath}/inventory/medication/:medicationId`, this.findInventoryByMedication.bind(this));
|
||||
this.router.get(`${pharmacyPath}/inventory/:id`, this.findInventoryById.bind(this));
|
||||
this.router.post(`${pharmacyPath}/inventory`, this.createInventory.bind(this));
|
||||
this.router.patch(`${pharmacyPath}/inventory/:id`, this.updateInventory.bind(this));
|
||||
this.router.post(`${pharmacyPath}/inventory/:id/adjust`, this.adjustInventory.bind(this));
|
||||
this.router.post(`${pharmacyPath}/inventory/:id/restock`, this.restockInventory.bind(this));
|
||||
|
||||
// Prescription Routes
|
||||
this.router.get(`${pharmacyPath}/prescriptions`, this.findAllPrescriptions.bind(this));
|
||||
this.router.get(`${pharmacyPath}/prescriptions/stats`, this.getPrescriptionStats.bind(this));
|
||||
this.router.get(`${pharmacyPath}/prescriptions/pending`, this.getPendingPrescriptions.bind(this));
|
||||
this.router.get(`${pharmacyPath}/prescriptions/controlled`, this.getControlledSubstancePrescriptions.bind(this));
|
||||
this.router.get(`${pharmacyPath}/prescriptions/patient/:patientId`, this.findPrescriptionsByPatient.bind(this));
|
||||
this.router.get(`${pharmacyPath}/prescriptions/consultation/:consultationId`, this.findPrescriptionsByConsultation.bind(this));
|
||||
this.router.get(`${pharmacyPath}/prescriptions/:id`, this.findPrescriptionById.bind(this));
|
||||
this.router.post(`${pharmacyPath}/prescriptions`, this.createPrescription.bind(this));
|
||||
this.router.patch(`${pharmacyPath}/prescriptions/:id`, this.updatePrescription.bind(this));
|
||||
this.router.post(`${pharmacyPath}/prescriptions/:id/cancel`, this.cancelPrescription.bind(this));
|
||||
this.router.post(`${pharmacyPath}/prescriptions/:id/refill`, this.useRefill.bind(this));
|
||||
|
||||
// Dispensation Routes
|
||||
this.router.get(`${pharmacyPath}/dispensations`, this.findAllDispensations.bind(this));
|
||||
this.router.get(`${pharmacyPath}/dispensations/stats`, this.getDispensationStats.bind(this));
|
||||
this.router.get(`${pharmacyPath}/dispensations/pending`, this.getPendingDispensations.bind(this));
|
||||
this.router.get(`${pharmacyPath}/dispensations/patient/:patientId`, this.findDispensationsByPatient.bind(this));
|
||||
this.router.get(`${pharmacyPath}/dispensations/patient/:patientId/history`, this.getPatientDispensationHistory.bind(this));
|
||||
this.router.get(`${pharmacyPath}/dispensations/prescription/:prescriptionId`, this.findDispensationsByPrescription.bind(this));
|
||||
this.router.get(`${pharmacyPath}/dispensations/:id`, this.findDispensationById.bind(this));
|
||||
this.router.post(`${pharmacyPath}/dispensations`, this.createDispensation.bind(this));
|
||||
this.router.post(`${pharmacyPath}/dispensations/:id/verify`, this.verifyDispensation.bind(this));
|
||||
this.router.post(`${pharmacyPath}/dispensations/:id/complete`, this.completeDispensation.bind(this));
|
||||
this.router.post(`${pharmacyPath}/dispensations/:id/cancel`, this.cancelDispensation.bind(this));
|
||||
this.router.post(`${pharmacyPath}/dispensations/:id/return`, this.returnDispensation.bind(this));
|
||||
}
|
||||
|
||||
private getTenantId(req: Request): string {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
if (!tenantId) {
|
||||
throw new Error('x-tenant-id header is required');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
// Medication Handlers
|
||||
private async findAllMedications(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: MedicationQueryDto = {
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as any,
|
||||
form: req.query.form as any,
|
||||
status: req.query.status as any,
|
||||
isControlledSubstance: req.query.isControlledSubstance === 'true' ? true : req.query.isControlledSubstance === 'false' ? false : undefined,
|
||||
requiresPrescription: req.query.requiresPrescription === 'true' ? true : req.query.requiresPrescription === 'false' ? false : undefined,
|
||||
page: req.query.page ? parseInt(req.query.page as string) : 1,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
|
||||
};
|
||||
|
||||
const result = await this.medicationService.findAll(tenantId, query);
|
||||
res.json({
|
||||
data: result.data,
|
||||
meta: {
|
||||
total: result.total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(result.total / (query.limit || 50)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMedicationCategories(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const categories = await this.medicationService.getCategories(tenantId);
|
||||
res.json({ data: categories });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getControlledSubstances(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const medications = await this.medicationService.findControlledSubstances(tenantId);
|
||||
res.json({ data: medications });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async searchMedications(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const searchTerm = req.query.q as string;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
|
||||
|
||||
if (!searchTerm) {
|
||||
res.status(400).json({ error: 'Search term is required', code: 'SEARCH_TERM_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
|
||||
const medications = await this.medicationService.searchByName(tenantId, searchTerm, limit);
|
||||
res.json({ data: medications });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findMedicationById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const medication = await this.medicationService.findById(tenantId, id);
|
||||
if (!medication) {
|
||||
res.status(404).json({ error: 'Medication not found', code: 'MEDICATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: medication });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dto: CreateMedicationDto = req.body;
|
||||
|
||||
const medication = await this.medicationService.create(tenantId, dto);
|
||||
res.status(201).json({ data: medication });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateMedicationDto = req.body;
|
||||
|
||||
const medication = await this.medicationService.update(tenantId, id, dto);
|
||||
if (!medication) {
|
||||
res.status(404).json({ error: 'Medication not found', code: 'MEDICATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: medication });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const deleted = await this.medicationService.softDelete(tenantId, id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Medication not found', code: 'MEDICATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Inventory Handlers
|
||||
private async findAllInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: InventoryQueryDto = {
|
||||
medicationId: req.query.medicationId as string,
|
||||
status: req.query.status as any,
|
||||
lowStockOnly: req.query.lowStockOnly === 'true',
|
||||
expiringOnly: req.query.expiringOnly === 'true',
|
||||
expiringWithinDays: req.query.expiringWithinDays ? parseInt(req.query.expiringWithinDays as string) : undefined,
|
||||
location: req.query.location as string,
|
||||
page: req.query.page ? parseInt(req.query.page as string) : 1,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
|
||||
};
|
||||
|
||||
const result = await this.inventoryService.findAll(tenantId, query);
|
||||
res.json({
|
||||
data: result.data,
|
||||
meta: {
|
||||
total: result.total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(result.total / (query.limit || 50)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getInventorySummary(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const summary = await this.inventoryService.getInventorySummary(tenantId);
|
||||
res.json({ data: summary });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLowStockAlerts(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const items = await this.inventoryService.getLowStockAlerts(tenantId);
|
||||
res.json({ data: items });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExpiringItems(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const withinDays = req.query.days ? parseInt(req.query.days as string) : 30;
|
||||
const items = await this.inventoryService.getExpiringItems(tenantId, withinDays);
|
||||
res.json({ data: items });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExpiredItems(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const items = await this.inventoryService.getExpiredItems(tenantId);
|
||||
res.json({ data: items });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findInventoryByMedication(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { medicationId } = req.params;
|
||||
|
||||
const items = await this.inventoryService.findByMedication(tenantId, medicationId);
|
||||
res.json({ data: items });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findInventoryById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const inventory = await this.inventoryService.findById(tenantId, id);
|
||||
if (!inventory) {
|
||||
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: inventory });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dto: CreateInventoryDto = req.body;
|
||||
|
||||
const inventory = await this.inventoryService.create(tenantId, dto);
|
||||
res.status(201).json({ data: inventory });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdateInventoryDto = req.body;
|
||||
|
||||
const inventory = await this.inventoryService.update(tenantId, id, dto);
|
||||
if (!inventory) {
|
||||
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: inventory });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async adjustInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: AdjustInventoryDto = req.body;
|
||||
|
||||
const inventory = await this.inventoryService.adjustStock(tenantId, id, dto);
|
||||
if (!inventory) {
|
||||
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: inventory });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async restockInventory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: RestockInventoryDto = req.body;
|
||||
|
||||
const inventory = await this.inventoryService.restock(tenantId, id, dto);
|
||||
if (!inventory) {
|
||||
res.status(404).json({ error: 'Inventory item not found', code: 'INVENTORY_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: inventory });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Prescription Handlers
|
||||
private async findAllPrescriptions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: PrescriptionQueryDto = {
|
||||
patientId: req.query.patientId as string,
|
||||
doctorId: req.query.doctorId as string,
|
||||
consultationId: req.query.consultationId as string,
|
||||
medicationId: req.query.medicationId as string,
|
||||
dateFrom: req.query.dateFrom as string,
|
||||
dateTo: req.query.dateTo as string,
|
||||
status: req.query.status as any,
|
||||
priority: req.query.priority as any,
|
||||
isControlledSubstance: req.query.isControlledSubstance === 'true' ? true : req.query.isControlledSubstance === 'false' ? false : undefined,
|
||||
page: req.query.page ? parseInt(req.query.page as string) : 1,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : 20,
|
||||
};
|
||||
|
||||
const result = await this.prescriptionService.findAll(tenantId, query);
|
||||
res.json({
|
||||
data: result.data,
|
||||
meta: {
|
||||
total: result.total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(result.total / (query.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPrescriptionStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const stats = await this.prescriptionService.getPrescriptionStats(tenantId);
|
||||
res.json({ data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPendingPrescriptions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const prescriptions = await this.prescriptionService.findPendingPrescriptions(tenantId);
|
||||
res.json({ data: prescriptions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getControlledSubstancePrescriptions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dateFrom = req.query.dateFrom as string;
|
||||
const dateTo = req.query.dateTo as string;
|
||||
|
||||
const prescriptions = await this.prescriptionService.findControlledSubstancePrescriptions(tenantId, dateFrom, dateTo);
|
||||
res.json({ data: prescriptions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findPrescriptionsByPatient(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { patientId } = req.params;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
|
||||
|
||||
const prescriptions = await this.prescriptionService.findByPatient(tenantId, patientId, limit);
|
||||
res.json({ data: prescriptions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findPrescriptionsByConsultation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { consultationId } = req.params;
|
||||
|
||||
const prescriptions = await this.prescriptionService.findByConsultation(tenantId, consultationId);
|
||||
res.json({ data: prescriptions });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findPrescriptionById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const prescription = await this.prescriptionService.findById(tenantId, id);
|
||||
if (!prescription) {
|
||||
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: prescription });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createPrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dto: CreatePrescriptionDto = req.body;
|
||||
|
||||
const prescription = await this.prescriptionService.create(tenantId, dto);
|
||||
res.status(201).json({ data: prescription });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updatePrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: UpdatePrescriptionDto = req.body;
|
||||
|
||||
const prescription = await this.prescriptionService.update(tenantId, id, dto);
|
||||
if (!prescription) {
|
||||
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: prescription });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelPrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: CancelPrescriptionDto = req.body;
|
||||
|
||||
const prescription = await this.prescriptionService.cancel(tenantId, id, dto);
|
||||
if (!prescription) {
|
||||
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: prescription });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async useRefill(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const prescription = await this.prescriptionService.useRefill(tenantId, id);
|
||||
if (!prescription) {
|
||||
res.status(404).json({ error: 'Prescription not found', code: 'PRESCRIPTION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: prescription });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispensation Handlers
|
||||
private async findAllDispensations(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const query: DispensationQueryDto = {
|
||||
prescriptionId: req.query.prescriptionId as string,
|
||||
patientId: req.query.patientId as string,
|
||||
medicationId: req.query.medicationId as string,
|
||||
dateFrom: req.query.dateFrom as string,
|
||||
dateTo: req.query.dateTo as string,
|
||||
status: req.query.status as any,
|
||||
type: req.query.type as any,
|
||||
page: req.query.page ? parseInt(req.query.page as string) : 1,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : 20,
|
||||
};
|
||||
|
||||
const result = await this.dispensationService.findAll(tenantId, query);
|
||||
res.json({
|
||||
data: result.data,
|
||||
meta: {
|
||||
total: result.total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(result.total / (query.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDispensationStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const stats = await this.dispensationService.getDispensationStats(tenantId);
|
||||
res.json({ data: stats });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPendingDispensations(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dispensations = await this.dispensationService.findPendingDispensations(tenantId);
|
||||
res.json({ data: dispensations });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findDispensationsByPatient(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { patientId } = req.params;
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20;
|
||||
|
||||
const dispensations = await this.dispensationService.findByPatient(tenantId, patientId, limit);
|
||||
res.json({ data: dispensations });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPatientDispensationHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { patientId } = req.params;
|
||||
const medicationId = req.query.medicationId as string;
|
||||
|
||||
const history = await this.dispensationService.getPatientDispensationHistory(tenantId, patientId, medicationId);
|
||||
res.json({ data: history });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findDispensationsByPrescription(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { prescriptionId } = req.params;
|
||||
|
||||
const dispensations = await this.dispensationService.findByPrescription(tenantId, prescriptionId);
|
||||
res.json({ data: dispensations });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async findDispensationById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const dispensation = await this.dispensationService.findById(tenantId, id);
|
||||
if (!dispensation) {
|
||||
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: dispensation });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const dto: CreateDispensationDto = req.body;
|
||||
|
||||
const dispensation = await this.dispensationService.create(tenantId, dto);
|
||||
res.status(201).json({ data: dispensation });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: VerifyDispensationDto = req.body;
|
||||
|
||||
const dispensation = await this.dispensationService.verify(tenantId, id, dto);
|
||||
if (!dispensation) {
|
||||
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: dispensation });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async completeDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: CompleteDispensationDto = req.body;
|
||||
|
||||
const dispensation = await this.dispensationService.complete(tenantId, id, dto);
|
||||
if (!dispensation) {
|
||||
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: dispensation });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: CancelDispensationDto = req.body;
|
||||
|
||||
const dispensation = await this.dispensationService.cancel(tenantId, id, dto);
|
||||
if (!dispensation) {
|
||||
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: dispensation });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async returnDispensation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = this.getTenantId(req);
|
||||
const { id } = req.params;
|
||||
const dto: ReturnDispensationDto = req.body;
|
||||
|
||||
const dispensation = await this.dispensationService.processReturn(tenantId, id, dto);
|
||||
if (!dispensation) {
|
||||
res.status(404).json({ error: 'Dispensation not found', code: 'DISPENSATION_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: dispensation });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
785
src/modules/pharmacy/dto/index.ts
Normal file
785
src/modules/pharmacy/dto/index.ts
Normal file
@ -0,0 +1,785 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
MedicationCategory,
|
||||
MedicationStatus,
|
||||
MedicationForm,
|
||||
ControlledSubstanceSchedule,
|
||||
InventoryStatus,
|
||||
PharmacyPrescriptionStatus,
|
||||
PrescriptionPriority,
|
||||
DispensationStatus,
|
||||
DispensationType,
|
||||
} from '../entities';
|
||||
|
||||
// Medication DTOs
|
||||
export class CreateMedicationDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
genericName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
brandName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'])
|
||||
category?: MedicationCategory;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'])
|
||||
form?: MedicationForm;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
strength?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
unit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
activeIngredient?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
manufacturer?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isControlledSubstance?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['none', 'schedule_i', 'schedule_ii', 'schedule_iii', 'schedule_iv', 'schedule_v'])
|
||||
controlledSchedule?: ControlledSubstanceSchedule;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresPrescription?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageConditions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contraindications?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sideEffects?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateMedicationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
genericName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
brandName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'])
|
||||
category?: MedicationCategory;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'])
|
||||
form?: MedicationForm;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
strength?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
unit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
activeIngredient?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
manufacturer?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isControlledSubstance?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['none', 'schedule_i', 'schedule_ii', 'schedule_iii', 'schedule_iv', 'schedule_v'])
|
||||
controlledSchedule?: ControlledSubstanceSchedule;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresPrescription?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
storageConditions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contraindications?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sideEffects?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'discontinued'])
|
||||
status?: MedicationStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
export class MedicationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'])
|
||||
category?: MedicationCategory;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'])
|
||||
form?: MedicationForm;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'discontinued'])
|
||||
status?: MedicationStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isControlledSubstance?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresPrescription?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Inventory DTOs
|
||||
export class CreateInventoryDto {
|
||||
@IsUUID()
|
||||
medicationId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
batchNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lotNumber?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quantityOnHand: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
reorderLevel?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
reorderQuantity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maximumStock?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
unitOfMeasure?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPerUnit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expirationDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
manufactureDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
shelfLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateInventoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
batchNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lotNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
reorderLevel?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
reorderQuantity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maximumStock?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
unitOfMeasure?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPerUnit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expirationDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
shelfLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class AdjustInventoryDto {
|
||||
@IsInt()
|
||||
adjustment: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
reference?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class RestockInventoryDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
batchNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lotNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expirationDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPerUnit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
reference?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class InventoryQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
medicationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['in_stock', 'low_stock', 'out_of_stock', 'expired'])
|
||||
status?: InventoryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
lowStockOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
expiringOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
expiringWithinDays?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Prescription DTOs
|
||||
export class CreatePrescriptionDto {
|
||||
@IsUUID()
|
||||
patientId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
consultationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originalPrescriptionId?: string;
|
||||
|
||||
@IsUUID()
|
||||
prescribingDoctorId: string;
|
||||
|
||||
@IsUUID()
|
||||
medicationId: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
medicationName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
medicationCode?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantityPrescribed: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
dosage: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
frequency: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
duration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
routeOfAdministration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instructions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
refillsAllowed?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isControlledSubstance?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['routine', 'urgent', 'stat'])
|
||||
priority?: PrescriptionPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicalNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expirationDate?: string;
|
||||
}
|
||||
|
||||
export class UpdatePrescriptionDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantityPrescribed?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
dosage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
frequency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
duration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
routeOfAdministration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instructions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
refillsAllowed?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['routine', 'urgent', 'stat'])
|
||||
priority?: PrescriptionPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
clinicalNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expirationDate?: string;
|
||||
}
|
||||
|
||||
export class CancelPrescriptionDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class PrescriptionQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
patientId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
doctorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
consultationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
medicationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['pending', 'partially_filled', 'filled', 'cancelled', 'expired'])
|
||||
status?: PharmacyPrescriptionStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['routine', 'urgent', 'stat'])
|
||||
priority?: PrescriptionPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isControlledSubstance?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Dispensation DTOs
|
||||
export class CreateDispensationDto {
|
||||
@IsUUID()
|
||||
prescriptionId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
inventoryId?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantityRequested: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantityDispensed: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['full', 'partial', 'refill'])
|
||||
type?: DispensationType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
dispensedBy?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
receivedBy?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
receiverRelationship?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
receiverIdentification?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
discountAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
insuranceCovered?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instructions?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
counselingProvided?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
counselingNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class VerifyDispensationDto {
|
||||
@IsUUID()
|
||||
verifiedBy: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CompleteDispensationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
receivedBy?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
receiverRelationship?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
receiverIdentification?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
signatureCaptured?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
counselingProvided?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
counselingNotes?: string;
|
||||
}
|
||||
|
||||
export class CancelDispensationDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class ReturnDispensationDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class DispensationQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
prescriptionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
patientId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
medicationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['pending', 'processing', 'completed', 'cancelled', 'returned'])
|
||||
status?: DispensationStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['full', 'partial', 'refill'])
|
||||
type?: DispensationType;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
139
src/modules/pharmacy/entities/dispensation.entity.ts
Normal file
139
src/modules/pharmacy/entities/dispensation.entity.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { PharmacyPrescription } from './prescription.entity';
|
||||
|
||||
export type DispensationStatus = 'pending' | 'processing' | 'completed' | 'cancelled' | 'returned';
|
||||
export type DispensationType = 'full' | 'partial' | 'refill';
|
||||
|
||||
@Entity({ name: 'dispensations', schema: 'clinica' })
|
||||
export class Dispensation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'dispensation_number', type: 'varchar', length: 50 })
|
||||
dispensationNumber: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'prescription_id', type: 'uuid' })
|
||||
prescriptionId: string;
|
||||
|
||||
@ManyToOne(() => PharmacyPrescription, (prescription) => prescription.dispensations)
|
||||
@JoinColumn({ name: 'prescription_id' })
|
||||
prescription: PharmacyPrescription;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'patient_id', type: 'uuid' })
|
||||
patientId: string;
|
||||
|
||||
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
|
||||
inventoryId?: string;
|
||||
|
||||
@Column({ name: 'medication_id', type: 'uuid' })
|
||||
medicationId: string;
|
||||
|
||||
@Column({ name: 'medication_name', type: 'varchar', length: 200 })
|
||||
medicationName: string;
|
||||
|
||||
@Column({ name: 'batch_number', type: 'varchar', length: 100, nullable: true })
|
||||
batchNumber?: string;
|
||||
|
||||
@Column({ name: 'lot_number', type: 'varchar', length: 100, nullable: true })
|
||||
lotNumber?: string;
|
||||
|
||||
@Column({ name: 'quantity_requested', type: 'int' })
|
||||
quantityRequested: number;
|
||||
|
||||
@Column({ name: 'quantity_dispensed', type: 'int' })
|
||||
quantityDispensed: number;
|
||||
|
||||
@Column({ type: 'enum', enum: ['full', 'partial', 'refill'], default: 'full' })
|
||||
type: DispensationType;
|
||||
|
||||
@Column({ type: 'enum', enum: ['pending', 'processing', 'completed', 'cancelled', 'returned'], default: 'pending' })
|
||||
status: DispensationStatus;
|
||||
|
||||
@Column({ name: 'dispensed_by', type: 'uuid', nullable: true })
|
||||
dispensedBy?: string;
|
||||
|
||||
@Column({ name: 'dispensed_at', type: 'timestamptz', nullable: true })
|
||||
dispensedAt?: Date;
|
||||
|
||||
@Column({ name: 'verified_by', type: 'uuid', nullable: true })
|
||||
verifiedBy?: string;
|
||||
|
||||
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
|
||||
verifiedAt?: Date;
|
||||
|
||||
@Column({ name: 'received_by', type: 'varchar', length: 200, nullable: true })
|
||||
receivedBy?: string;
|
||||
|
||||
@Column({ name: 'receiver_relationship', type: 'varchar', length: 100, nullable: true })
|
||||
receiverRelationship?: string;
|
||||
|
||||
@Column({ name: 'receiver_identification', type: 'varchar', length: 100, nullable: true })
|
||||
receiverIdentification?: string;
|
||||
|
||||
@Column({ name: 'signature_captured', type: 'boolean', default: false })
|
||||
signatureCaptured: boolean;
|
||||
|
||||
@Column({ name: 'unit_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
unitPrice?: number;
|
||||
|
||||
@Column({ name: 'total_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
totalPrice?: number;
|
||||
|
||||
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
discountAmount: number;
|
||||
|
||||
@Column({ name: 'insurance_covered', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
insuranceCovered: number;
|
||||
|
||||
@Column({ name: 'patient_copay', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
patientCopay?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
instructions?: string;
|
||||
|
||||
@Column({ name: 'counseling_provided', type: 'boolean', default: false })
|
||||
counselingProvided: boolean;
|
||||
|
||||
@Column({ name: 'counseling_notes', type: 'text', nullable: true })
|
||||
counselingNotes?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date;
|
||||
|
||||
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
|
||||
cancellationReason?: string;
|
||||
|
||||
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
|
||||
returnedAt?: Date;
|
||||
|
||||
@Column({ name: 'return_reason', type: 'text', nullable: true })
|
||||
returnReason?: string;
|
||||
|
||||
@Column({ name: 'return_quantity', type: 'int', nullable: true })
|
||||
returnQuantity?: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
22
src/modules/pharmacy/entities/index.ts
Normal file
22
src/modules/pharmacy/entities/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export {
|
||||
Medication,
|
||||
MedicationCategory,
|
||||
MedicationStatus,
|
||||
MedicationForm,
|
||||
ControlledSubstanceSchedule,
|
||||
} from './medication.entity';
|
||||
export {
|
||||
PharmacyInventory,
|
||||
InventoryStatus,
|
||||
StockMovement,
|
||||
} from './pharmacy-inventory.entity';
|
||||
export {
|
||||
PharmacyPrescription,
|
||||
PharmacyPrescriptionStatus,
|
||||
PrescriptionPriority,
|
||||
} from './prescription.entity';
|
||||
export {
|
||||
Dispensation,
|
||||
DispensationStatus,
|
||||
DispensationType,
|
||||
} from './dispensation.entity';
|
||||
97
src/modules/pharmacy/entities/medication.entity.ts
Normal file
97
src/modules/pharmacy/entities/medication.entity.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export type MedicationCategory = 'antibiotic' | 'analgesic' | 'anti_inflammatory' | 'antihypertensive' | 'antidiabetic' | 'psychotropic' | 'vitamin' | 'vaccine' | 'other';
|
||||
export type MedicationStatus = 'active' | 'inactive' | 'discontinued';
|
||||
export type MedicationForm = 'tablet' | 'capsule' | 'syrup' | 'injection' | 'cream' | 'ointment' | 'drops' | 'inhaler' | 'patch' | 'suppository' | 'other';
|
||||
export type ControlledSubstanceSchedule = 'none' | 'schedule_i' | 'schedule_ii' | 'schedule_iii' | 'schedule_iv' | 'schedule_v';
|
||||
|
||||
@Entity({ name: 'medications', schema: 'clinica' })
|
||||
export class Medication {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'generic_name', type: 'varchar', length: 200, nullable: true })
|
||||
genericName?: string;
|
||||
|
||||
@Column({ name: 'brand_name', type: 'varchar', length: 200, nullable: true })
|
||||
brandName?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ['antibiotic', 'analgesic', 'anti_inflammatory', 'antihypertensive', 'antidiabetic', 'psychotropic', 'vitamin', 'vaccine', 'other'], default: 'other' })
|
||||
category: MedicationCategory;
|
||||
|
||||
@Column({ type: 'enum', enum: ['tablet', 'capsule', 'syrup', 'injection', 'cream', 'ointment', 'drops', 'inhaler', 'patch', 'suppository', 'other'], default: 'tablet' })
|
||||
form: MedicationForm;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
strength?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
unit?: string;
|
||||
|
||||
@Column({ name: 'active_ingredient', type: 'varchar', length: 300, nullable: true })
|
||||
activeIngredient?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
manufacturer?: string;
|
||||
|
||||
@Column({ name: 'is_controlled_substance', type: 'boolean', default: false })
|
||||
isControlledSubstance: boolean;
|
||||
|
||||
@Column({ name: 'controlled_schedule', type: 'enum', enum: ['none', 'schedule_i', 'schedule_ii', 'schedule_iii', 'schedule_iv', 'schedule_v'], default: 'none' })
|
||||
controlledSchedule: ControlledSubstanceSchedule;
|
||||
|
||||
@Column({ name: 'requires_prescription', type: 'boolean', default: true })
|
||||
requiresPrescription: boolean;
|
||||
|
||||
@Column({ name: 'storage_conditions', type: 'text', nullable: true })
|
||||
storageConditions?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
contraindications?: string;
|
||||
|
||||
@Column({ name: 'side_effects', type: 'text', nullable: true })
|
||||
sideEffects?: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
price?: number;
|
||||
|
||||
@Column({ name: 'cost_price', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
costPrice?: number;
|
||||
|
||||
@Column({ type: 'enum', enum: ['active', 'inactive', 'discontinued'], default: 'active' })
|
||||
status: MedicationStatus;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 0 })
|
||||
displayOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
103
src/modules/pharmacy/entities/pharmacy-inventory.entity.ts
Normal file
103
src/modules/pharmacy/entities/pharmacy-inventory.entity.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Medication } from './medication.entity';
|
||||
|
||||
export type InventoryStatus = 'in_stock' | 'low_stock' | 'out_of_stock' | 'expired';
|
||||
|
||||
export interface StockMovement {
|
||||
date: Date;
|
||||
type: 'in' | 'out' | 'adjustment';
|
||||
quantity: number;
|
||||
reason: string;
|
||||
reference?: string;
|
||||
performedBy: string;
|
||||
}
|
||||
|
||||
@Entity({ name: 'pharmacy_inventory', schema: 'clinica' })
|
||||
export class PharmacyInventory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'medication_id', type: 'uuid' })
|
||||
medicationId: string;
|
||||
|
||||
@ManyToOne(() => Medication)
|
||||
@JoinColumn({ name: 'medication_id' })
|
||||
medication: Medication;
|
||||
|
||||
@Column({ name: 'batch_number', type: 'varchar', length: 100, nullable: true })
|
||||
batchNumber?: string;
|
||||
|
||||
@Column({ name: 'lot_number', type: 'varchar', length: 100, nullable: true })
|
||||
lotNumber?: string;
|
||||
|
||||
@Column({ name: 'quantity_on_hand', type: 'int', default: 0 })
|
||||
quantityOnHand: number;
|
||||
|
||||
@Column({ name: 'quantity_reserved', type: 'int', default: 0 })
|
||||
quantityReserved: number;
|
||||
|
||||
@Column({ name: 'quantity_available', type: 'int', default: 0 })
|
||||
quantityAvailable: number;
|
||||
|
||||
@Column({ name: 'reorder_level', type: 'int', default: 10 })
|
||||
reorderLevel: number;
|
||||
|
||||
@Column({ name: 'reorder_quantity', type: 'int', default: 50 })
|
||||
reorderQuantity: number;
|
||||
|
||||
@Column({ name: 'maximum_stock', type: 'int', nullable: true })
|
||||
maximumStock?: number;
|
||||
|
||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 50, default: 'unit' })
|
||||
unitOfMeasure: string;
|
||||
|
||||
@Column({ name: 'cost_per_unit', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
costPerUnit?: number;
|
||||
|
||||
@Column({ name: 'expiration_date', type: 'date', nullable: true })
|
||||
expirationDate?: Date;
|
||||
|
||||
@Column({ name: 'manufacture_date', type: 'date', nullable: true })
|
||||
manufactureDate?: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
location?: string;
|
||||
|
||||
@Column({ name: 'shelf_location', type: 'varchar', length: 100, nullable: true })
|
||||
shelfLocation?: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ['in_stock', 'low_stock', 'out_of_stock', 'expired'], default: 'in_stock' })
|
||||
status: InventoryStatus;
|
||||
|
||||
@Column({ name: 'last_restock_date', type: 'timestamptz', nullable: true })
|
||||
lastRestockDate?: Date;
|
||||
|
||||
@Column({ name: 'last_dispensed_date', type: 'timestamptz', nullable: true })
|
||||
lastDispensedDate?: Date;
|
||||
|
||||
@Column({ name: 'stock_movements', type: 'jsonb', nullable: true })
|
||||
stockMovements?: StockMovement[];
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
114
src/modules/pharmacy/entities/prescription.entity.ts
Normal file
114
src/modules/pharmacy/entities/prescription.entity.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { Dispensation } from './dispensation.entity';
|
||||
|
||||
export type PharmacyPrescriptionStatus = 'pending' | 'partially_filled' | 'filled' | 'cancelled' | 'expired';
|
||||
export type PrescriptionPriority = 'routine' | 'urgent' | 'stat';
|
||||
|
||||
@Entity({ name: 'pharmacy_prescriptions', schema: 'clinica' })
|
||||
export class PharmacyPrescription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'prescription_number', type: 'varchar', length: 50 })
|
||||
prescriptionNumber: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'patient_id', type: 'uuid' })
|
||||
patientId: string;
|
||||
|
||||
@Column({ name: 'consultation_id', type: 'uuid', nullable: true })
|
||||
consultationId?: string;
|
||||
|
||||
@Column({ name: 'original_prescription_id', type: 'uuid', nullable: true })
|
||||
originalPrescriptionId?: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'prescribing_doctor_id', type: 'uuid' })
|
||||
prescribingDoctorId: string;
|
||||
|
||||
@Column({ name: 'prescription_date', type: 'timestamptz', default: () => 'NOW()' })
|
||||
prescriptionDate: Date;
|
||||
|
||||
@Column({ name: 'expiration_date', type: 'date', nullable: true })
|
||||
expirationDate?: Date;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'medication_id', type: 'uuid' })
|
||||
medicationId: string;
|
||||
|
||||
@Column({ name: 'medication_name', type: 'varchar', length: 200 })
|
||||
medicationName: string;
|
||||
|
||||
@Column({ name: 'medication_code', type: 'varchar', length: 50, nullable: true })
|
||||
medicationCode?: string;
|
||||
|
||||
@Column({ name: 'quantity_prescribed', type: 'int' })
|
||||
quantityPrescribed: number;
|
||||
|
||||
@Column({ name: 'quantity_dispensed', type: 'int', default: 0 })
|
||||
quantityDispensed: number;
|
||||
|
||||
@Column({ name: 'quantity_remaining', type: 'int' })
|
||||
quantityRemaining: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
dosage: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
frequency: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
duration?: string;
|
||||
|
||||
@Column({ name: 'route_of_administration', type: 'varchar', length: 100, nullable: true })
|
||||
routeOfAdministration?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
instructions?: string;
|
||||
|
||||
@Column({ name: 'refills_allowed', type: 'int', default: 0 })
|
||||
refillsAllowed: number;
|
||||
|
||||
@Column({ name: 'refills_remaining', type: 'int', default: 0 })
|
||||
refillsRemaining: number;
|
||||
|
||||
@Column({ name: 'is_controlled_substance', type: 'boolean', default: false })
|
||||
isControlledSubstance: boolean;
|
||||
|
||||
@Column({ type: 'enum', enum: ['routine', 'urgent', 'stat'], default: 'routine' })
|
||||
priority: PrescriptionPriority;
|
||||
|
||||
@Column({ type: 'enum', enum: ['pending', 'partially_filled', 'filled', 'cancelled', 'expired'], default: 'pending' })
|
||||
status: PharmacyPrescriptionStatus;
|
||||
|
||||
@Column({ name: 'clinical_notes', type: 'text', nullable: true })
|
||||
clinicalNotes?: string;
|
||||
|
||||
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||
cancelledAt?: Date;
|
||||
|
||||
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
|
||||
cancellationReason?: string;
|
||||
|
||||
@OneToMany(() => Dispensation, (dispensation) => dispensation.prescription)
|
||||
dispensations?: Dispensation[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
44
src/modules/pharmacy/index.ts
Normal file
44
src/modules/pharmacy/index.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export { PharmacyModule, PharmacyModuleOptions } from './pharmacy.module';
|
||||
export {
|
||||
Medication,
|
||||
MedicationCategory,
|
||||
MedicationStatus,
|
||||
MedicationForm,
|
||||
ControlledSubstanceSchedule,
|
||||
PharmacyInventory,
|
||||
InventoryStatus,
|
||||
StockMovement,
|
||||
PharmacyPrescription,
|
||||
PharmacyPrescriptionStatus,
|
||||
PrescriptionPriority,
|
||||
Dispensation,
|
||||
DispensationStatus,
|
||||
DispensationType,
|
||||
} from './entities';
|
||||
export {
|
||||
MedicationService,
|
||||
PharmacyInventoryService,
|
||||
PrescriptionService,
|
||||
DispensationService,
|
||||
} from './services';
|
||||
export { PharmacyController } from './controllers';
|
||||
export {
|
||||
CreateMedicationDto,
|
||||
UpdateMedicationDto,
|
||||
MedicationQueryDto,
|
||||
CreateInventoryDto,
|
||||
UpdateInventoryDto,
|
||||
AdjustInventoryDto,
|
||||
RestockInventoryDto,
|
||||
InventoryQueryDto,
|
||||
CreatePrescriptionDto,
|
||||
UpdatePrescriptionDto,
|
||||
CancelPrescriptionDto,
|
||||
PrescriptionQueryDto,
|
||||
CreateDispensationDto,
|
||||
VerifyDispensationDto,
|
||||
CompleteDispensationDto,
|
||||
CancelDispensationDto,
|
||||
ReturnDispensationDto,
|
||||
DispensationQueryDto,
|
||||
} from './dto';
|
||||
19
src/modules/pharmacy/pharmacy.module.ts
Normal file
19
src/modules/pharmacy/pharmacy.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PharmacyController } from './controllers';
|
||||
|
||||
export interface PharmacyModuleOptions {
|
||||
dataSource: DataSource;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export class PharmacyModule {
|
||||
public router: Router;
|
||||
private controller: PharmacyController;
|
||||
|
||||
constructor(options: PharmacyModuleOptions) {
|
||||
const { dataSource, basePath = '/api' } = options;
|
||||
this.controller = new PharmacyController(dataSource, basePath);
|
||||
this.router = this.controller.router;
|
||||
}
|
||||
}
|
||||
377
src/modules/pharmacy/services/dispensation.service.ts
Normal file
377
src/modules/pharmacy/services/dispensation.service.ts
Normal file
@ -0,0 +1,377 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Dispensation, PharmacyPrescription, PharmacyInventory } from '../entities';
|
||||
import {
|
||||
CreateDispensationDto,
|
||||
VerifyDispensationDto,
|
||||
CompleteDispensationDto,
|
||||
CancelDispensationDto,
|
||||
ReturnDispensationDto,
|
||||
DispensationQueryDto,
|
||||
} from '../dto';
|
||||
import { PrescriptionService } from './prescription.service';
|
||||
import { PharmacyInventoryService } from './pharmacy-inventory.service';
|
||||
|
||||
export class DispensationService {
|
||||
private repository: Repository<Dispensation>;
|
||||
private prescriptionRepository: Repository<PharmacyPrescription>;
|
||||
private inventoryRepository: Repository<PharmacyInventory>;
|
||||
private prescriptionService: PrescriptionService;
|
||||
private inventoryService: PharmacyInventoryService;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(Dispensation);
|
||||
this.prescriptionRepository = dataSource.getRepository(PharmacyPrescription);
|
||||
this.inventoryRepository = dataSource.getRepository(PharmacyInventory);
|
||||
this.prescriptionService = new PrescriptionService(dataSource);
|
||||
this.inventoryService = new PharmacyInventoryService(dataSource);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: DispensationQueryDto): Promise<{ data: Dispensation[]; total: number }> {
|
||||
const {
|
||||
prescriptionId,
|
||||
patientId,
|
||||
medicationId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
status,
|
||||
type,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
} = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('disp')
|
||||
.leftJoinAndSelect('disp.prescription', 'rx')
|
||||
.where('disp.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (prescriptionId) {
|
||||
queryBuilder.andWhere('disp.prescription_id = :prescriptionId', { prescriptionId });
|
||||
}
|
||||
|
||||
if (patientId) {
|
||||
queryBuilder.andWhere('disp.patient_id = :patientId', { patientId });
|
||||
}
|
||||
|
||||
if (medicationId) {
|
||||
queryBuilder.andWhere('disp.medication_id = :medicationId', { medicationId });
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
queryBuilder.andWhere('disp.created_at >= :dateFrom', { dateFrom });
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
queryBuilder.andWhere('disp.created_at <= :dateTo', { dateTo });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('disp.status = :status', { status });
|
||||
}
|
||||
|
||||
if (type) {
|
||||
queryBuilder.andWhere('disp.type = :type', { type });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('disp.created_at', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<Dispensation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['prescription'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByDispensationNumber(tenantId: string, dispensationNumber: string): Promise<Dispensation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { dispensationNumber, tenantId },
|
||||
relations: ['prescription'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByPrescription(tenantId: string, prescriptionId: string): Promise<Dispensation[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, prescriptionId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByPatient(tenantId: string, patientId: string, limit: number = 20): Promise<Dispensation[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, patientId },
|
||||
relations: ['prescription'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findPendingDispensations(tenantId: string): Promise<Dispensation[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, status: 'pending' },
|
||||
relations: ['prescription'],
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateDispensationDto): Promise<Dispensation> {
|
||||
const prescription = await this.prescriptionRepository.findOne({
|
||||
where: { id: dto.prescriptionId, tenantId },
|
||||
});
|
||||
|
||||
if (!prescription) {
|
||||
throw new Error('Prescription not found');
|
||||
}
|
||||
|
||||
if (prescription.status === 'cancelled' || prescription.status === 'expired') {
|
||||
throw new Error('Cannot dispense cancelled or expired prescription');
|
||||
}
|
||||
|
||||
if (prescription.status === 'filled' && prescription.refillsRemaining <= 0) {
|
||||
throw new Error('Prescription fully filled with no refills remaining');
|
||||
}
|
||||
|
||||
if (dto.quantityDispensed > prescription.quantityRemaining) {
|
||||
throw new Error(`Cannot dispense more than remaining quantity (${prescription.quantityRemaining})`);
|
||||
}
|
||||
|
||||
const dispensationNumber = await this.generateDispensationNumber(tenantId);
|
||||
|
||||
let inventory: PharmacyInventory | null = null;
|
||||
if (dto.inventoryId) {
|
||||
inventory = await this.inventoryRepository.findOne({
|
||||
where: { id: dto.inventoryId, tenantId },
|
||||
});
|
||||
|
||||
if (!inventory) {
|
||||
throw new Error('Inventory item not found');
|
||||
}
|
||||
|
||||
if (inventory.quantityAvailable < dto.quantityDispensed) {
|
||||
throw new Error('Insufficient inventory');
|
||||
}
|
||||
}
|
||||
|
||||
const type = dto.type || (
|
||||
prescription.quantityDispensed === 0
|
||||
? (dto.quantityDispensed < prescription.quantityPrescribed ? 'partial' : 'full')
|
||||
: 'refill'
|
||||
);
|
||||
|
||||
const totalPrice = dto.unitPrice
|
||||
? dto.unitPrice * dto.quantityDispensed - (dto.discountAmount || 0)
|
||||
: undefined;
|
||||
|
||||
const patientCopay = totalPrice !== undefined
|
||||
? totalPrice - (dto.insuranceCovered || 0)
|
||||
: undefined;
|
||||
|
||||
const dispensation = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
dispensationNumber,
|
||||
patientId: prescription.patientId,
|
||||
medicationId: prescription.medicationId,
|
||||
medicationName: prescription.medicationName,
|
||||
batchNumber: inventory?.batchNumber,
|
||||
lotNumber: inventory?.lotNumber,
|
||||
type,
|
||||
totalPrice,
|
||||
patientCopay,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const saved = await this.repository.save(dispensation);
|
||||
|
||||
if (inventory) {
|
||||
await this.inventoryService.reserveStock(tenantId, inventory.id, dto.quantityDispensed, dispensationNumber);
|
||||
}
|
||||
|
||||
return this.findById(tenantId, saved.id) as Promise<Dispensation>;
|
||||
}
|
||||
|
||||
async verify(tenantId: string, id: string, dto: VerifyDispensationDto): Promise<Dispensation | null> {
|
||||
const dispensation = await this.findById(tenantId, id);
|
||||
if (!dispensation) return null;
|
||||
|
||||
if (dispensation.status !== 'pending') {
|
||||
throw new Error('Can only verify pending dispensations');
|
||||
}
|
||||
|
||||
dispensation.status = 'processing';
|
||||
dispensation.verifiedBy = dto.verifiedBy;
|
||||
dispensation.verifiedAt = new Date();
|
||||
|
||||
if (dto.notes) {
|
||||
dispensation.notes = dispensation.notes
|
||||
? `${dispensation.notes}\n\nVerification note: ${dto.notes}`
|
||||
: `Verification note: ${dto.notes}`;
|
||||
}
|
||||
|
||||
return this.repository.save(dispensation);
|
||||
}
|
||||
|
||||
async complete(tenantId: string, id: string, dto: CompleteDispensationDto): Promise<Dispensation | null> {
|
||||
const dispensation = await this.findById(tenantId, id);
|
||||
if (!dispensation) return null;
|
||||
|
||||
if (dispensation.status !== 'processing' && dispensation.status !== 'pending') {
|
||||
throw new Error('Can only complete pending or processing dispensations');
|
||||
}
|
||||
|
||||
dispensation.status = 'completed';
|
||||
dispensation.dispensedAt = new Date();
|
||||
|
||||
if (dto.receivedBy) dispensation.receivedBy = dto.receivedBy;
|
||||
if (dto.receiverRelationship) dispensation.receiverRelationship = dto.receiverRelationship;
|
||||
if (dto.receiverIdentification) dispensation.receiverIdentification = dto.receiverIdentification;
|
||||
if (dto.signatureCaptured !== undefined) dispensation.signatureCaptured = dto.signatureCaptured;
|
||||
if (dto.counselingProvided !== undefined) dispensation.counselingProvided = dto.counselingProvided;
|
||||
if (dto.counselingNotes) dispensation.counselingNotes = dto.counselingNotes;
|
||||
|
||||
const saved = await this.repository.save(dispensation);
|
||||
|
||||
await this.prescriptionService.updateDispensedQuantity(
|
||||
tenantId,
|
||||
dispensation.prescriptionId,
|
||||
dispensation.quantityDispensed
|
||||
);
|
||||
|
||||
if (dispensation.inventoryId) {
|
||||
await this.inventoryService.dispenseStock(
|
||||
tenantId,
|
||||
dispensation.inventoryId,
|
||||
dispensation.quantityDispensed,
|
||||
dispensation.dispensationNumber,
|
||||
dispensation.dispensedBy
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(tenantId, saved.id);
|
||||
}
|
||||
|
||||
async cancel(tenantId: string, id: string, dto: CancelDispensationDto): Promise<Dispensation | null> {
|
||||
const dispensation = await this.findById(tenantId, id);
|
||||
if (!dispensation) return null;
|
||||
|
||||
if (dispensation.status === 'completed') {
|
||||
throw new Error('Cannot cancel completed dispensation. Use return instead.');
|
||||
}
|
||||
|
||||
dispensation.status = 'cancelled';
|
||||
dispensation.cancelledAt = new Date();
|
||||
dispensation.cancellationReason = dto.reason;
|
||||
|
||||
const saved = await this.repository.save(dispensation);
|
||||
|
||||
if (dispensation.inventoryId) {
|
||||
await this.inventoryService.releaseReservation(
|
||||
tenantId,
|
||||
dispensation.inventoryId,
|
||||
dispensation.quantityDispensed
|
||||
);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async processReturn(tenantId: string, id: string, dto: ReturnDispensationDto): Promise<Dispensation | null> {
|
||||
const dispensation = await this.findById(tenantId, id);
|
||||
if (!dispensation) return null;
|
||||
|
||||
if (dispensation.status !== 'completed') {
|
||||
throw new Error('Can only return completed dispensations');
|
||||
}
|
||||
|
||||
if (dto.quantity > dispensation.quantityDispensed) {
|
||||
throw new Error('Return quantity cannot exceed dispensed quantity');
|
||||
}
|
||||
|
||||
dispensation.status = 'returned';
|
||||
dispensation.returnedAt = new Date();
|
||||
dispensation.returnReason = dto.reason;
|
||||
dispensation.returnQuantity = dto.quantity;
|
||||
|
||||
const saved = await this.repository.save(dispensation);
|
||||
|
||||
const prescription = await this.prescriptionRepository.findOne({
|
||||
where: { id: dispensation.prescriptionId, tenantId },
|
||||
});
|
||||
|
||||
if (prescription) {
|
||||
prescription.quantityDispensed -= dto.quantity;
|
||||
prescription.quantityRemaining += dto.quantity;
|
||||
|
||||
if (prescription.status === 'filled') {
|
||||
prescription.status = 'partially_filled';
|
||||
}
|
||||
|
||||
await this.prescriptionRepository.save(prescription);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async getDispensationStats(tenantId: string): Promise<{
|
||||
pendingCount: number;
|
||||
processingCount: number;
|
||||
completedTodayCount: number;
|
||||
totalDispensedToday: number;
|
||||
}> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [pendingCount, processingCount, completedToday] = await Promise.all([
|
||||
this.repository.count({ where: { tenantId, status: 'pending' } }),
|
||||
this.repository.count({ where: { tenantId, status: 'processing' } }),
|
||||
this.repository.createQueryBuilder('disp')
|
||||
.select('COUNT(*)', 'count')
|
||||
.addSelect('COALESCE(SUM(disp.quantity_dispensed), 0)', 'total')
|
||||
.where('disp.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('disp.status = :status', { status: 'completed' })
|
||||
.andWhere('disp.dispensed_at >= :today', { today })
|
||||
.getRawOne(),
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingCount,
|
||||
processingCount,
|
||||
completedTodayCount: parseInt(completedToday?.count || '0', 10),
|
||||
totalDispensedToday: parseInt(completedToday?.total || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
async getPatientDispensationHistory(tenantId: string, patientId: string, medicationId?: string): Promise<Dispensation[]> {
|
||||
const queryBuilder = this.repository.createQueryBuilder('disp')
|
||||
.leftJoinAndSelect('disp.prescription', 'rx')
|
||||
.where('disp.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('disp.patient_id = :patientId', { patientId })
|
||||
.andWhere('disp.status = :status', { status: 'completed' });
|
||||
|
||||
if (medicationId) {
|
||||
queryBuilder.andWhere('disp.medication_id = :medicationId', { medicationId });
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('disp.dispensed_at', 'DESC')
|
||||
.take(50)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async generateDispensationNumber(tenantId: string): Promise<string> {
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
const count = await this.repository.createQueryBuilder('disp')
|
||||
.where('disp.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('DATE(disp.created_at) = CURRENT_DATE')
|
||||
.getCount();
|
||||
|
||||
const sequence = (count + 1).toString().padStart(4, '0');
|
||||
return `DISP-${dateStr}-${sequence}`;
|
||||
}
|
||||
}
|
||||
4
src/modules/pharmacy/services/index.ts
Normal file
4
src/modules/pharmacy/services/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { MedicationService } from './medication.service';
|
||||
export { PharmacyInventoryService } from './pharmacy-inventory.service';
|
||||
export { PrescriptionService } from './prescription.service';
|
||||
export { DispensationService } from './dispensation.service';
|
||||
144
src/modules/pharmacy/services/medication.service.ts
Normal file
144
src/modules/pharmacy/services/medication.service.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Medication } from '../entities';
|
||||
import { CreateMedicationDto, UpdateMedicationDto, MedicationQueryDto } from '../dto';
|
||||
|
||||
export class MedicationService {
|
||||
private repository: Repository<Medication>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(Medication);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: MedicationQueryDto): Promise<{ data: Medication[]; total: number }> {
|
||||
const { search, category, form, status, isControlledSubstance, requiresPrescription, page = 1, limit = 50 } = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('med')
|
||||
.where('med.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('med.deleted_at IS NULL');
|
||||
|
||||
if (category) {
|
||||
queryBuilder.andWhere('med.category = :category', { category });
|
||||
}
|
||||
|
||||
if (form) {
|
||||
queryBuilder.andWhere('med.form = :form', { form });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('med.status = :status', { status });
|
||||
}
|
||||
|
||||
if (isControlledSubstance !== undefined) {
|
||||
queryBuilder.andWhere('med.is_controlled_substance = :isControlledSubstance', { isControlledSubstance });
|
||||
}
|
||||
|
||||
if (requiresPrescription !== undefined) {
|
||||
queryBuilder.andWhere('med.requires_prescription = :requiresPrescription', { requiresPrescription });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(med.code ILIKE :search OR med.name ILIKE :search OR med.generic_name ILIKE :search OR med.brand_name ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('med.category', 'ASC')
|
||||
.addOrderBy('med.display_order', 'ASC')
|
||||
.addOrderBy('med.name', 'ASC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<Medication | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCode(tenantId: string, code: string): Promise<Medication | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByIds(tenantId: string, ids: string[]): Promise<Medication[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
return this.repository.createQueryBuilder('med')
|
||||
.where('med.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('med.id IN (:...ids)', { ids })
|
||||
.andWhere('med.deleted_at IS NULL')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findActiveByCategory(tenantId: string, category: string): Promise<Medication[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, category: category as any, status: 'active' },
|
||||
order: { displayOrder: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findControlledSubstances(tenantId: string): Promise<Medication[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, isControlledSubstance: true, status: 'active' },
|
||||
order: { controlledSchedule: 'ASC', name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateMedicationDto): Promise<Medication> {
|
||||
const medication = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
});
|
||||
return this.repository.save(medication);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateMedicationDto): Promise<Medication | null> {
|
||||
const medication = await this.findById(tenantId, id);
|
||||
if (!medication) return null;
|
||||
|
||||
Object.assign(medication, dto);
|
||||
return this.repository.save(medication);
|
||||
}
|
||||
|
||||
async softDelete(tenantId: string, id: string): Promise<boolean> {
|
||||
const medication = await this.findById(tenantId, id);
|
||||
if (!medication) return false;
|
||||
|
||||
await this.repository.softDelete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getCategories(tenantId: string): Promise<{ category: string; count: number }[]> {
|
||||
const result = await this.repository.createQueryBuilder('med')
|
||||
.select('med.category', 'category')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('med.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('med.deleted_at IS NULL')
|
||||
.andWhere('med.status = :status', { status: 'active' })
|
||||
.groupBy('med.category')
|
||||
.orderBy('med.category', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map(r => ({ category: r.category, count: parseInt(r.count, 10) }));
|
||||
}
|
||||
|
||||
async searchByName(tenantId: string, searchTerm: string, limit: number = 20): Promise<Medication[]> {
|
||||
return this.repository.createQueryBuilder('med')
|
||||
.where('med.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('med.deleted_at IS NULL')
|
||||
.andWhere('med.status = :status', { status: 'active' })
|
||||
.andWhere(
|
||||
'(med.name ILIKE :search OR med.generic_name ILIKE :search OR med.brand_name ILIKE :search)',
|
||||
{ search: `%${searchTerm}%` }
|
||||
)
|
||||
.orderBy('med.name', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
374
src/modules/pharmacy/services/pharmacy-inventory.service.ts
Normal file
374
src/modules/pharmacy/services/pharmacy-inventory.service.ts
Normal file
@ -0,0 +1,374 @@
|
||||
import { DataSource, Repository, LessThanOrEqual, MoreThan } from 'typeorm';
|
||||
import { PharmacyInventory, Medication, InventoryStatus, StockMovement } from '../entities';
|
||||
import {
|
||||
CreateInventoryDto,
|
||||
UpdateInventoryDto,
|
||||
AdjustInventoryDto,
|
||||
RestockInventoryDto,
|
||||
InventoryQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
export class PharmacyInventoryService {
|
||||
private repository: Repository<PharmacyInventory>;
|
||||
private medicationRepository: Repository<Medication>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(PharmacyInventory);
|
||||
this.medicationRepository = dataSource.getRepository(Medication);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: InventoryQueryDto): Promise<{ data: PharmacyInventory[]; total: number }> {
|
||||
const { medicationId, status, lowStockOnly, expiringOnly, expiringWithinDays, location, page = 1, limit = 50 } = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.medication', 'med')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (medicationId) {
|
||||
queryBuilder.andWhere('inv.medication_id = :medicationId', { medicationId });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('inv.status = :status', { status });
|
||||
}
|
||||
|
||||
if (lowStockOnly) {
|
||||
queryBuilder.andWhere('inv.quantity_available <= inv.reorder_level');
|
||||
}
|
||||
|
||||
if (expiringOnly || expiringWithinDays) {
|
||||
const daysToCheck = expiringWithinDays || 30;
|
||||
const expirationThreshold = new Date();
|
||||
expirationThreshold.setDate(expirationThreshold.getDate() + daysToCheck);
|
||||
queryBuilder.andWhere('inv.expiration_date <= :expirationThreshold', { expirationThreshold });
|
||||
queryBuilder.andWhere('inv.expiration_date >= CURRENT_DATE');
|
||||
}
|
||||
|
||||
if (location) {
|
||||
queryBuilder.andWhere('inv.location ILIKE :location', { location: `%${location}%` });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('inv.status', 'ASC')
|
||||
.addOrderBy('med.name', 'ASC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<PharmacyInventory | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['medication'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByMedication(tenantId: string, medicationId: string): Promise<PharmacyInventory[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, medicationId },
|
||||
relations: ['medication'],
|
||||
order: { expirationDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAvailableByMedication(tenantId: string, medicationId: string): Promise<PharmacyInventory[]> {
|
||||
return this.repository.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.medication', 'med')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.medication_id = :medicationId', { medicationId })
|
||||
.andWhere('inv.quantity_available > 0')
|
||||
.andWhere('inv.status != :expired', { expired: 'expired' })
|
||||
.andWhere('(inv.expiration_date IS NULL OR inv.expiration_date > CURRENT_DATE)')
|
||||
.orderBy('inv.expiration_date', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateInventoryDto): Promise<PharmacyInventory> {
|
||||
const medication = await this.medicationRepository.findOne({
|
||||
where: { id: dto.medicationId, tenantId },
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
throw new Error('Medication not found');
|
||||
}
|
||||
|
||||
const quantityAvailable = dto.quantityOnHand;
|
||||
const status = this.calculateStatus(quantityAvailable, dto.reorderLevel || 10, dto.expirationDate);
|
||||
|
||||
const inventory = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
quantityAvailable,
|
||||
quantityReserved: 0,
|
||||
status,
|
||||
lastRestockDate: new Date(),
|
||||
stockMovements: [{
|
||||
date: new Date(),
|
||||
type: 'in',
|
||||
quantity: dto.quantityOnHand,
|
||||
reason: 'Initial stock',
|
||||
performedBy: 'system',
|
||||
}],
|
||||
});
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateInventoryDto): Promise<PharmacyInventory | null> {
|
||||
const inventory = await this.findById(tenantId, id);
|
||||
if (!inventory) return null;
|
||||
|
||||
Object.assign(inventory, dto);
|
||||
|
||||
if (dto.expirationDate) {
|
||||
inventory.status = this.calculateStatus(
|
||||
inventory.quantityAvailable,
|
||||
inventory.reorderLevel,
|
||||
dto.expirationDate
|
||||
);
|
||||
}
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async adjustStock(tenantId: string, id: string, dto: AdjustInventoryDto): Promise<PharmacyInventory | null> {
|
||||
const inventory = await this.findById(tenantId, id);
|
||||
if (!inventory) return null;
|
||||
|
||||
const newQuantity = inventory.quantityOnHand + dto.adjustment;
|
||||
if (newQuantity < 0) {
|
||||
throw new Error('Adjustment would result in negative stock');
|
||||
}
|
||||
|
||||
const movement: StockMovement = {
|
||||
date: new Date(),
|
||||
type: dto.adjustment > 0 ? 'in' : 'adjustment',
|
||||
quantity: dto.adjustment,
|
||||
reason: dto.reason,
|
||||
reference: dto.reference,
|
||||
performedBy: dto.performedBy || 'system',
|
||||
};
|
||||
|
||||
inventory.quantityOnHand = newQuantity;
|
||||
inventory.quantityAvailable = newQuantity - inventory.quantityReserved;
|
||||
inventory.stockMovements = inventory.stockMovements || [];
|
||||
inventory.stockMovements.push(movement);
|
||||
inventory.status = this.calculateStatus(
|
||||
inventory.quantityAvailable,
|
||||
inventory.reorderLevel,
|
||||
inventory.expirationDate?.toString()
|
||||
);
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async restock(tenantId: string, id: string, dto: RestockInventoryDto): Promise<PharmacyInventory | null> {
|
||||
const inventory = await this.findById(tenantId, id);
|
||||
if (!inventory) return null;
|
||||
|
||||
const movement: StockMovement = {
|
||||
date: new Date(),
|
||||
type: 'in',
|
||||
quantity: dto.quantity,
|
||||
reason: 'Restock',
|
||||
reference: dto.reference,
|
||||
performedBy: dto.performedBy || 'system',
|
||||
};
|
||||
|
||||
inventory.quantityOnHand += dto.quantity;
|
||||
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
|
||||
inventory.lastRestockDate = new Date();
|
||||
inventory.stockMovements = inventory.stockMovements || [];
|
||||
inventory.stockMovements.push(movement);
|
||||
|
||||
if (dto.batchNumber) inventory.batchNumber = dto.batchNumber;
|
||||
if (dto.lotNumber) inventory.lotNumber = dto.lotNumber;
|
||||
if (dto.expirationDate) inventory.expirationDate = new Date(dto.expirationDate);
|
||||
if (dto.costPerUnit !== undefined) inventory.costPerUnit = dto.costPerUnit;
|
||||
|
||||
inventory.status = this.calculateStatus(
|
||||
inventory.quantityAvailable,
|
||||
inventory.reorderLevel,
|
||||
inventory.expirationDate?.toString()
|
||||
);
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async reserveStock(tenantId: string, id: string, quantity: number, reference: string): Promise<PharmacyInventory | null> {
|
||||
const inventory = await this.findById(tenantId, id);
|
||||
if (!inventory) return null;
|
||||
|
||||
if (inventory.quantityAvailable < quantity) {
|
||||
throw new Error('Insufficient available stock');
|
||||
}
|
||||
|
||||
inventory.quantityReserved += quantity;
|
||||
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
|
||||
inventory.status = this.calculateStatus(
|
||||
inventory.quantityAvailable,
|
||||
inventory.reorderLevel,
|
||||
inventory.expirationDate?.toString()
|
||||
);
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async releaseReservation(tenantId: string, id: string, quantity: number): Promise<PharmacyInventory | null> {
|
||||
const inventory = await this.findById(tenantId, id);
|
||||
if (!inventory) return null;
|
||||
|
||||
inventory.quantityReserved = Math.max(0, inventory.quantityReserved - quantity);
|
||||
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
|
||||
inventory.status = this.calculateStatus(
|
||||
inventory.quantityAvailable,
|
||||
inventory.reorderLevel,
|
||||
inventory.expirationDate?.toString()
|
||||
);
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async dispenseStock(tenantId: string, id: string, quantity: number, reference: string, performedBy?: string): Promise<PharmacyInventory | null> {
|
||||
const inventory = await this.findById(tenantId, id);
|
||||
if (!inventory) return null;
|
||||
|
||||
if (inventory.quantityOnHand < quantity) {
|
||||
throw new Error('Insufficient stock');
|
||||
}
|
||||
|
||||
const movement: StockMovement = {
|
||||
date: new Date(),
|
||||
type: 'out',
|
||||
quantity: -quantity,
|
||||
reason: 'Dispensed',
|
||||
reference,
|
||||
performedBy: performedBy || 'system',
|
||||
};
|
||||
|
||||
inventory.quantityOnHand -= quantity;
|
||||
inventory.quantityReserved = Math.max(0, inventory.quantityReserved - quantity);
|
||||
inventory.quantityAvailable = inventory.quantityOnHand - inventory.quantityReserved;
|
||||
inventory.lastDispensedDate = new Date();
|
||||
inventory.stockMovements = inventory.stockMovements || [];
|
||||
inventory.stockMovements.push(movement);
|
||||
inventory.status = this.calculateStatus(
|
||||
inventory.quantityAvailable,
|
||||
inventory.reorderLevel,
|
||||
inventory.expirationDate?.toString()
|
||||
);
|
||||
|
||||
return this.repository.save(inventory);
|
||||
}
|
||||
|
||||
async getLowStockAlerts(tenantId: string): Promise<PharmacyInventory[]> {
|
||||
return this.repository.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.medication', 'med')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.quantity_available <= inv.reorder_level')
|
||||
.andWhere('inv.status != :expired', { expired: 'expired' })
|
||||
.orderBy('inv.quantity_available', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getExpiringItems(tenantId: string, withinDays: number = 30): Promise<PharmacyInventory[]> {
|
||||
const expirationThreshold = new Date();
|
||||
expirationThreshold.setDate(expirationThreshold.getDate() + withinDays);
|
||||
|
||||
return this.repository.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.medication', 'med')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.expiration_date <= :expirationThreshold', { expirationThreshold })
|
||||
.andWhere('inv.expiration_date >= CURRENT_DATE')
|
||||
.andWhere('inv.quantity_on_hand > 0')
|
||||
.orderBy('inv.expiration_date', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getExpiredItems(tenantId: string): Promise<PharmacyInventory[]> {
|
||||
return this.repository.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.medication', 'med')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.expiration_date < CURRENT_DATE')
|
||||
.andWhere('inv.quantity_on_hand > 0')
|
||||
.orderBy('inv.expiration_date', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getInventorySummary(tenantId: string): Promise<{
|
||||
totalItems: number;
|
||||
lowStockCount: number;
|
||||
outOfStockCount: number;
|
||||
expiringCount: number;
|
||||
expiredCount: number;
|
||||
totalValue: number;
|
||||
}> {
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
|
||||
const [totalItems, lowStockCount, outOfStockCount, expiringCount, expiredCount, valueResult] = await Promise.all([
|
||||
this.repository.count({ where: { tenantId } }),
|
||||
this.repository.createQueryBuilder('inv')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.quantity_available <= inv.reorder_level')
|
||||
.andWhere('inv.quantity_available > 0')
|
||||
.getCount(),
|
||||
this.repository.count({ where: { tenantId, status: 'out_of_stock' } }),
|
||||
this.repository.createQueryBuilder('inv')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.expiration_date <= :threshold', { threshold: thirtyDaysFromNow })
|
||||
.andWhere('inv.expiration_date >= CURRENT_DATE')
|
||||
.getCount(),
|
||||
this.repository.createQueryBuilder('inv')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('inv.expiration_date < CURRENT_DATE')
|
||||
.getCount(),
|
||||
this.repository.createQueryBuilder('inv')
|
||||
.select('SUM(inv.quantity_on_hand * inv.cost_per_unit)', 'total')
|
||||
.where('inv.tenant_id = :tenantId', { tenantId })
|
||||
.getRawOne(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalItems,
|
||||
lowStockCount,
|
||||
outOfStockCount,
|
||||
expiringCount,
|
||||
expiredCount,
|
||||
totalValue: parseFloat(valueResult?.total || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
async updateExpiredStatus(tenantId: string): Promise<number> {
|
||||
const result = await this.repository.createQueryBuilder()
|
||||
.update(PharmacyInventory)
|
||||
.set({ status: 'expired' })
|
||||
.where('tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('expiration_date < CURRENT_DATE')
|
||||
.andWhere('status != :expired', { expired: 'expired' })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
private calculateStatus(quantityAvailable: number, reorderLevel: number, expirationDate?: string): InventoryStatus {
|
||||
if (expirationDate) {
|
||||
const expDate = new Date(expirationDate);
|
||||
if (expDate < new Date()) {
|
||||
return 'expired';
|
||||
}
|
||||
}
|
||||
|
||||
if (quantityAvailable <= 0) {
|
||||
return 'out_of_stock';
|
||||
}
|
||||
|
||||
if (quantityAvailable <= reorderLevel) {
|
||||
return 'low_stock';
|
||||
}
|
||||
|
||||
return 'in_stock';
|
||||
}
|
||||
}
|
||||
310
src/modules/pharmacy/services/prescription.service.ts
Normal file
310
src/modules/pharmacy/services/prescription.service.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { PharmacyPrescription, Medication, Dispensation } from '../entities';
|
||||
import {
|
||||
CreatePrescriptionDto,
|
||||
UpdatePrescriptionDto,
|
||||
CancelPrescriptionDto,
|
||||
PrescriptionQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
export class PrescriptionService {
|
||||
private repository: Repository<PharmacyPrescription>;
|
||||
private medicationRepository: Repository<Medication>;
|
||||
private dispensationRepository: Repository<Dispensation>;
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.repository = dataSource.getRepository(PharmacyPrescription);
|
||||
this.medicationRepository = dataSource.getRepository(Medication);
|
||||
this.dispensationRepository = dataSource.getRepository(Dispensation);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: PrescriptionQueryDto): Promise<{ data: PharmacyPrescription[]; total: number }> {
|
||||
const {
|
||||
patientId,
|
||||
doctorId,
|
||||
consultationId,
|
||||
medicationId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
status,
|
||||
priority,
|
||||
isControlledSubstance,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
} = query;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('rx')
|
||||
.leftJoinAndSelect('rx.dispensations', 'disp')
|
||||
.where('rx.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (patientId) {
|
||||
queryBuilder.andWhere('rx.patient_id = :patientId', { patientId });
|
||||
}
|
||||
|
||||
if (doctorId) {
|
||||
queryBuilder.andWhere('rx.prescribing_doctor_id = :doctorId', { doctorId });
|
||||
}
|
||||
|
||||
if (consultationId) {
|
||||
queryBuilder.andWhere('rx.consultation_id = :consultationId', { consultationId });
|
||||
}
|
||||
|
||||
if (medicationId) {
|
||||
queryBuilder.andWhere('rx.medication_id = :medicationId', { medicationId });
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
queryBuilder.andWhere('rx.prescription_date >= :dateFrom', { dateFrom });
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
queryBuilder.andWhere('rx.prescription_date <= :dateTo', { dateTo });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('rx.status = :status', { status });
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
queryBuilder.andWhere('rx.priority = :priority', { priority });
|
||||
}
|
||||
|
||||
if (isControlledSubstance !== undefined) {
|
||||
queryBuilder.andWhere('rx.is_controlled_substance = :isControlledSubstance', { isControlledSubstance });
|
||||
}
|
||||
|
||||
queryBuilder
|
||||
.orderBy('rx.prescription_date', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string): Promise<PharmacyPrescription | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['dispensations'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByPrescriptionNumber(tenantId: string, prescriptionNumber: string): Promise<PharmacyPrescription | null> {
|
||||
return this.repository.findOne({
|
||||
where: { prescriptionNumber, tenantId },
|
||||
relations: ['dispensations'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByPatient(tenantId: string, patientId: string, limit: number = 20): Promise<PharmacyPrescription[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, patientId },
|
||||
relations: ['dispensations'],
|
||||
order: { prescriptionDate: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findByConsultation(tenantId: string, consultationId: string): Promise<PharmacyPrescription[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, consultationId },
|
||||
relations: ['dispensations'],
|
||||
order: { prescriptionDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findPendingPrescriptions(tenantId: string): Promise<PharmacyPrescription[]> {
|
||||
return this.repository.find({
|
||||
where: { tenantId, status: 'pending' },
|
||||
relations: ['dispensations'],
|
||||
order: { priority: 'DESC', prescriptionDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findControlledSubstancePrescriptions(tenantId: string, dateFrom?: string, dateTo?: string): Promise<PharmacyPrescription[]> {
|
||||
const queryBuilder = this.repository.createQueryBuilder('rx')
|
||||
.leftJoinAndSelect('rx.dispensations', 'disp')
|
||||
.where('rx.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('rx.is_controlled_substance = true');
|
||||
|
||||
if (dateFrom) {
|
||||
queryBuilder.andWhere('rx.prescription_date >= :dateFrom', { dateFrom });
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
queryBuilder.andWhere('rx.prescription_date <= :dateTo', { dateTo });
|
||||
}
|
||||
|
||||
return queryBuilder
|
||||
.orderBy('rx.prescription_date', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreatePrescriptionDto): Promise<PharmacyPrescription> {
|
||||
const medication = await this.medicationRepository.findOne({
|
||||
where: { id: dto.medicationId, tenantId },
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
throw new Error('Medication not found');
|
||||
}
|
||||
|
||||
const prescriptionNumber = await this.generatePrescriptionNumber(tenantId);
|
||||
|
||||
const expirationDate = dto.expirationDate
|
||||
? new Date(dto.expirationDate)
|
||||
: this.calculateExpirationDate(dto.isControlledSubstance || medication.isControlledSubstance);
|
||||
|
||||
const prescription = this.repository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
prescriptionNumber,
|
||||
quantityRemaining: dto.quantityPrescribed,
|
||||
quantityDispensed: 0,
|
||||
refillsRemaining: dto.refillsAllowed || 0,
|
||||
isControlledSubstance: dto.isControlledSubstance ?? medication.isControlledSubstance,
|
||||
expirationDate,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
return this.repository.save(prescription);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdatePrescriptionDto): Promise<PharmacyPrescription | null> {
|
||||
const prescription = await this.findById(tenantId, id);
|
||||
if (!prescription) return null;
|
||||
|
||||
if (prescription.status === 'filled' || prescription.status === 'cancelled') {
|
||||
throw new Error('Cannot update filled or cancelled prescription');
|
||||
}
|
||||
|
||||
if (dto.quantityPrescribed !== undefined && dto.quantityPrescribed < prescription.quantityDispensed) {
|
||||
throw new Error('New quantity cannot be less than quantity already dispensed');
|
||||
}
|
||||
|
||||
Object.assign(prescription, dto);
|
||||
|
||||
if (dto.quantityPrescribed !== undefined) {
|
||||
prescription.quantityRemaining = dto.quantityPrescribed - prescription.quantityDispensed;
|
||||
}
|
||||
|
||||
if (dto.refillsAllowed !== undefined) {
|
||||
const usedRefills = (prescription.refillsAllowed || 0) - prescription.refillsRemaining;
|
||||
prescription.refillsRemaining = Math.max(0, dto.refillsAllowed - usedRefills);
|
||||
}
|
||||
|
||||
return this.repository.save(prescription);
|
||||
}
|
||||
|
||||
async cancel(tenantId: string, id: string, dto: CancelPrescriptionDto): Promise<PharmacyPrescription | null> {
|
||||
const prescription = await this.findById(tenantId, id);
|
||||
if (!prescription) return null;
|
||||
|
||||
if (prescription.status === 'filled') {
|
||||
throw new Error('Cannot cancel filled prescription');
|
||||
}
|
||||
|
||||
prescription.status = 'cancelled';
|
||||
prescription.cancelledAt = new Date();
|
||||
prescription.cancellationReason = dto.reason;
|
||||
|
||||
return this.repository.save(prescription);
|
||||
}
|
||||
|
||||
async updateDispensedQuantity(tenantId: string, id: string, quantityDispensed: number): Promise<PharmacyPrescription | null> {
|
||||
const prescription = await this.findById(tenantId, id);
|
||||
if (!prescription) return null;
|
||||
|
||||
prescription.quantityDispensed += quantityDispensed;
|
||||
prescription.quantityRemaining = prescription.quantityPrescribed - prescription.quantityDispensed;
|
||||
|
||||
if (prescription.quantityRemaining <= 0) {
|
||||
prescription.status = 'filled';
|
||||
} else if (prescription.quantityDispensed > 0) {
|
||||
prescription.status = 'partially_filled';
|
||||
}
|
||||
|
||||
return this.repository.save(prescription);
|
||||
}
|
||||
|
||||
async useRefill(tenantId: string, id: string): Promise<PharmacyPrescription | null> {
|
||||
const prescription = await this.findById(tenantId, id);
|
||||
if (!prescription) return null;
|
||||
|
||||
if (prescription.refillsRemaining <= 0) {
|
||||
throw new Error('No refills remaining');
|
||||
}
|
||||
|
||||
prescription.refillsRemaining -= 1;
|
||||
prescription.quantityRemaining = prescription.quantityPrescribed;
|
||||
prescription.quantityDispensed = 0;
|
||||
prescription.status = 'pending';
|
||||
|
||||
return this.repository.save(prescription);
|
||||
}
|
||||
|
||||
async checkExpiredPrescriptions(tenantId: string): Promise<number> {
|
||||
const result = await this.repository.createQueryBuilder()
|
||||
.update(PharmacyPrescription)
|
||||
.set({ status: 'expired' })
|
||||
.where('tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('expiration_date < CURRENT_DATE')
|
||||
.andWhere('status IN (:...statuses)', { statuses: ['pending', 'partially_filled'] })
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
async getPrescriptionStats(tenantId: string): Promise<{
|
||||
pendingCount: number;
|
||||
partiallyFilledCount: number;
|
||||
filledTodayCount: number;
|
||||
controlledSubstanceCount: number;
|
||||
}> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [pendingCount, partiallyFilledCount, filledTodayCount, controlledSubstanceCount] = await Promise.all([
|
||||
this.repository.count({ where: { tenantId, status: 'pending' } }),
|
||||
this.repository.count({ where: { tenantId, status: 'partially_filled' } }),
|
||||
this.repository.createQueryBuilder('rx')
|
||||
.where('rx.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('rx.status = :status', { status: 'filled' })
|
||||
.andWhere('rx.updated_at >= :today', { today })
|
||||
.getCount(),
|
||||
this.repository.count({
|
||||
where: { tenantId, isControlledSubstance: true, status: 'pending' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingCount,
|
||||
partiallyFilledCount,
|
||||
filledTodayCount,
|
||||
controlledSubstanceCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async generatePrescriptionNumber(tenantId: string): Promise<string> {
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
const count = await this.repository.createQueryBuilder('rx')
|
||||
.where('rx.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('DATE(rx.created_at) = CURRENT_DATE')
|
||||
.getCount();
|
||||
|
||||
const sequence = (count + 1).toString().padStart(4, '0');
|
||||
return `RX-${dateStr}-${sequence}`;
|
||||
}
|
||||
|
||||
private calculateExpirationDate(isControlledSubstance: boolean): Date {
|
||||
const expirationDate = new Date();
|
||||
if (isControlledSubstance) {
|
||||
expirationDate.setDate(expirationDate.getDate() + 30);
|
||||
} else {
|
||||
expirationDate.setMonth(expirationDate.getMonth() + 6);
|
||||
}
|
||||
return expirationDate;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user