diff --git a/api/pom.xml b/api/pom.xml index 82c6cf46..ce5fda71 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -48,6 +48,11 @@ stockmanagement-api + + org.openmrs + event-api + + org.openmrs.module uiframework-api diff --git a/api/src/main/java/org/openmrs/module/billing/BillingModuleActivator.java b/api/src/main/java/org/openmrs/module/billing/BillingModuleActivator.java index a06b9301..5b41a463 100644 --- a/api/src/main/java/org/openmrs/module/billing/BillingModuleActivator.java +++ b/api/src/main/java/org/openmrs/module/billing/BillingModuleActivator.java @@ -13,10 +13,19 @@ */ package org.openmrs.module.billing; +import java.util.ArrayList; +import java.util.List; + +import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.openmrs.api.context.Context; +import org.openmrs.event.Event; import org.openmrs.module.BaseModuleActivator; +import org.openmrs.module.DaemonToken; +import org.openmrs.module.DaemonTokenAware; import org.openmrs.module.Module; import org.openmrs.module.ModuleFactory; +import org.openmrs.module.billing.api.billing.BillingEventListener; import org.openmrs.module.billing.web.CashierWebConstants; import org.openmrs.module.web.WebModuleUtil; @@ -24,7 +33,12 @@ * This class contains the logic that is run every time this module is either started or stopped. */ @Slf4j -public class BillingModuleActivator extends BaseModuleActivator { +@Setter +public class BillingModuleActivator extends BaseModuleActivator implements DaemonTokenAware { + + private DaemonToken daemonToken; + + private final List subscribedListeners = new ArrayList<>(); /** * @see BaseModuleActivator#contextRefreshed() @@ -40,6 +54,8 @@ public void contextRefreshed() { @Override public void started() { log.info("OpenMRS Billing Module started"); + + subscribeBillingEventListeners(); } /** @@ -47,9 +63,41 @@ public void started() { */ @Override public void stopped() { + unsubscribeBillingEventListeners(); + Module module = ModuleFactory.getModuleById(CashierWebConstants.OPENHMIS_CASHIER_MODULE_ID); WebModuleUtil.unloadFilters(module); log.info("OpenMRS Billing Module stopped"); } + + private void subscribeBillingEventListeners() { + List listeners = Context.getRegisteredComponents(BillingEventListener.class); + for (BillingEventListener listener : listeners) { + try { + listener.setDaemonToken(daemonToken); + Event.subscribe(listener.getSubscribedClass(), listener.getSubscribedAction().name(), listener); + subscribedListeners.add(listener); + log.info("Subscribed {} to {} {} events", listener.getClass().getSimpleName(), + listener.getSubscribedClass().getSimpleName(), listener.getSubscribedAction()); + } + catch (Exception e) { + log.error("Failed to subscribe {}", listener.getClass().getSimpleName(), e); + } + } + } + + private void unsubscribeBillingEventListeners() { + for (BillingEventListener listener : subscribedListeners) { + try { + Event.unsubscribe(listener.getSubscribedClass(), listener.getSubscribedAction(), listener); + log.info("Unsubscribed {} from {} {} events", listener.getClass().getSimpleName(), + listener.getSubscribedClass().getSimpleName(), listener.getSubscribedAction()); + } + catch (Exception e) { + log.error("Failed to unsubscribe {}", listener.getClass().getSimpleName(), e); + } + } + subscribedListeners.clear(); + } } diff --git a/api/src/main/java/org/openmrs/module/billing/advice/GenerateBillFromOrderAdvice.java b/api/src/main/java/org/openmrs/module/billing/advice/GenerateBillFromOrderAdvice.java deleted file mode 100644 index 14434572..00000000 --- a/api/src/main/java/org/openmrs/module/billing/advice/GenerateBillFromOrderAdvice.java +++ /dev/null @@ -1,223 +0,0 @@ -package org.openmrs.module.billing.advice; - -import lombok.extern.slf4j.Slf4j; -import org.openmrs.DrugOrder; -import org.openmrs.Order; -import org.openmrs.Patient; -import org.openmrs.PatientProgram; -import org.openmrs.Provider; -import org.openmrs.TestOrder; -import org.openmrs.User; -import org.openmrs.api.ProgramWorkflowService; -import org.openmrs.api.context.Context; -import org.openmrs.module.billing.api.BillExemptionService; -import org.openmrs.module.billing.api.BillService; -import org.openmrs.module.billing.api.BillableServiceService; -import org.openmrs.module.billing.api.CashPointService; -import org.openmrs.module.billing.api.ItemPriceService; -import org.openmrs.module.billing.api.evaluator.ExemptionRuleEngine; -import org.openmrs.module.billing.api.model.Bill; -import org.openmrs.module.billing.api.model.BillExemption; -import org.openmrs.module.billing.api.model.BillLineItem; -import org.openmrs.module.billing.api.model.BillStatus; -import org.openmrs.module.billing.api.model.BillableService; -import org.openmrs.module.billing.api.model.BillableServiceStatus; -import org.openmrs.module.billing.api.model.CashPoint; -import org.openmrs.module.billing.api.model.CashierItemPrice; -import org.openmrs.module.billing.api.model.ExemptionType; -import org.openmrs.module.billing.api.search.BillableServiceSearch; -import org.openmrs.module.stockmanagement.api.StockManagementService; -import org.openmrs.module.stockmanagement.api.model.StockItem; -import org.springframework.aop.AfterReturningAdvice; - -import javax.annotation.Nullable; -import javax.validation.constraints.Null; -import java.lang.reflect.Method; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Slf4j -public class GenerateBillFromOrderAdvice implements AfterReturningAdvice { - - final BillService billService = Context.getService(BillService.class); - - final StockManagementService stockService = Context.getService(StockManagementService.class); - - final ItemPriceService priceService = Context.getService(ItemPriceService.class); - - final CashPointService cashPointService = Context.getService(CashPointService.class); - - final ExemptionRuleEngine exemptionRuleEngine = Context.getRegisteredComponent("ruleEngine", ExemptionRuleEngine.class); - - final BillExemptionService billExemptionService = Context.getService(BillExemptionService.class); - - /** - * This is called immediately an order is saved - */ - @Override - public void afterReturning(Object returnValue, Method method, Object[] args, Object target) { - try { - ProgramWorkflowService workflowService = Context.getProgramWorkflowService(); - if (method.getName().equals("saveOrder") && args.length > 0 && args[0] instanceof Order) { - Order order = (Order) args[0]; - - // Check if the order is a discontinuation, revision, or renewal - if (order.getAction().equals(Order.Action.DISCONTINUE) || order.getAction().equals(Order.Action.REVISE) - || order.getAction().equals(Order.Action.RENEW)) { - // Do nothing for these actions - return; - } - - Patient patient = order.getPatient(); - String cashierUUID = Context.getAuthenticatedUser().getUuid(); - - if (order instanceof DrugOrder) { - DrugOrder drugOrder = (DrugOrder) order; - Integer drugID = drugOrder.getDrug() != null ? drugOrder.getDrug().getDrugId() : 0; - double drugQuantity = drugOrder.getQuantity() != null ? drugOrder.getQuantity() : 0.0; - List stockItems = stockService.getStockItemByDrug(drugID); - - if (!stockItems.isEmpty()) { - // check from the list for all exemptions - boolean isExempted = checkIfOrderIsExempted(workflowService, order, ExemptionType.COMMODITY); - BillStatus lineItemStatus = isExempted ? BillStatus.EXEMPTED : BillStatus.PENDING; - addBillItemToBill(order, patient, cashierUUID, stockItems.get(0), null, (int) drugQuantity, - order.getDateActivated(), lineItemStatus); - } - } else if (order instanceof TestOrder) { - TestOrder testOrder = (TestOrder) order; - BillableServiceSearch searchTemplate = new BillableServiceSearch(); - searchTemplate.setConceptUuid(testOrder.getConcept().getUuid()); - searchTemplate.setServiceStatus(BillableServiceStatus.ENABLED); - - BillableServiceService service = Context.getService(BillableServiceService.class); - List searchResult = service.getBillableServices(searchTemplate, null); - if (!searchResult.isEmpty()) { - boolean isExempted = checkIfOrderIsExempted(workflowService, order, ExemptionType.SERVICE); - BillStatus lineItemStatus = isExempted ? BillStatus.EXEMPTED : BillStatus.PENDING; - addBillItemToBill(order, patient, cashierUUID, null, searchResult.get(0), 1, - order.getDateActivated(), lineItemStatus); - } - } - } - } - catch (Exception e) { - log.error("Error intercepting order before creation: {}", e.getMessage(), e); - } - } - - private boolean checkIfOrderIsExempted(ProgramWorkflowService workflowService, Order order, - ExemptionType exemptionType) { - if (order == null || order.getConcept() == null) { - return false; - } - List exemptions = billExemptionService.getExemptionsByConcept(order.getConcept(), exemptionType, - false); - - if (exemptions == null || exemptions.isEmpty()) { - return false; - } - - Map variables = buildVariablesMap(order, workflowService); - - for (BillExemption exemption : exemptions) { - if (exemptionRuleEngine.isExemptionApplicable(exemption, variables)) { - return true; - } - } - - return false; - } - - private Map buildVariablesMap(Order order, ProgramWorkflowService workflowService) { - Map variables = new HashMap<>(); - - Patient patient = order.getPatient(); - variables.put("patient", patient); - // We cannot call getAge() method from Java Script - if (patient != null) { - variables.put("patientAge", patient.getAge()); - } - - Map orderData = new HashMap<>(); - orderData.put("uuid", order.getUuid()); - if (order.getConcept() != null) { - orderData.put("conceptId", order.getConcept().getConceptId()); - } - variables.put("order", orderData); - - List programs = workflowService.getPatientPrograms(patient, null, null, null, new Date(), null, - false); - List activePrograms = programs.stream().filter(PatientProgram::getActive) - .map(pp -> pp.getProgram().getName()).collect(Collectors.toList()); - - variables.put("activePrograms", activePrograms); - - return variables; - } - - /** - * Adds a bill item to the cashier module - * - * @param patient - * @param cashierUUID - */ - public void addBillItemToBill(Order order, Patient patient, String cashierUUID, StockItem stockitem, - BillableService service, Integer quantity, Date orderDate, BillStatus lineItemStatus) { - try { - // Search for a bill - Bill activeBill = new Bill(); - activeBill.setPatient(patient); - activeBill.setStatus(BillStatus.PENDING); - BillLineItem billLineItem = new BillLineItem(); - List itemPrices = new ArrayList<>(); - if (stockitem != null) { - billLineItem.setItem(stockitem); - itemPrices = priceService.getItemPrice(stockitem); - } else if (service != null) { - billLineItem.setBillableService(service); - itemPrices = priceService.getServicePrice(service); - } - - if (!itemPrices.isEmpty()) { - //List matchingPrices = itemPrices.stream().filter(p -> p.getPaymentMode().getUuid().equals(fetchPatientPayment(order))).collect(Collectors.toList()); - // billLineItem.setPrice(matchingPrices.isEmpty() ? itemPrices.get(0).getPrice() : matchingPrices.get(0).getPrice()); - billLineItem.setPrice(itemPrices.get(0).getPrice()); - } else { - if (stockitem != null && stockitem.getPurchasePrice() != null) { - billLineItem.setPrice(stockitem.getPurchasePrice()); - } else { - billLineItem.setPrice(BigDecimal.ZERO); - } - } - billLineItem.setQuantity(quantity); - billLineItem.setPaymentStatus(lineItemStatus); - billLineItem.setLineItemOrder(0); - billLineItem.setOrder(order); - - // Bill - User user = Context.getAuthenticatedUser(); - List providers = new ArrayList<>(Context.getProviderService().getProvidersByPerson(user.getPerson())); - - if (!providers.isEmpty()) { - activeBill.setCashier(providers.get(0)); - List cashPoints = cashPointService.getAllCashPoints(false); - activeBill.setCashPoint(cashPoints.get(0)); - activeBill.addLineItem(billLineItem); - activeBill.setStatus(BillStatus.PENDING); - billService.saveBill(activeBill); - } else { - log.error("User is not a provider"); - } - - } - catch (Exception ex) { - log.error("Error sending the bill item: {}", ex.getMessage(), ex); - } - } -} diff --git a/api/src/main/java/org/openmrs/module/billing/advice/OrderCreationMethodBeforeAdvice.java b/api/src/main/java/org/openmrs/module/billing/advice/OrderCreationMethodBeforeAdvice.java deleted file mode 100644 index 3338dcd3..00000000 --- a/api/src/main/java/org/openmrs/module/billing/advice/OrderCreationMethodBeforeAdvice.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * The contents of this file are subject to the OpenMRS Public License - * Version 1.1 (the "License"); you may not use this file except in - * compliance with the License. You may obtain a copy of the License at - * http://license.openmrs.org - * - * Software distributed under the License is distributed on an "AS IS" - * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the - * License for the specific language governing rights and limitations - * under the License. - * - * Copyright (C) OpenMRS, LLC. All Rights Reserved. - */ -package org.openmrs.module.billing.advice; - -import java.lang.reflect.Method; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.stream.Collectors; - -import lombok.extern.slf4j.Slf4j; -import org.openmrs.DrugOrder; -import org.openmrs.Order; -import org.openmrs.Patient; -import org.openmrs.Provider; -import org.openmrs.TestOrder; -import org.openmrs.User; -import org.openmrs.VisitAttribute; -import org.openmrs.api.OrderService; -import org.openmrs.api.context.Context; -import org.openmrs.module.billing.api.BillService; -import org.openmrs.module.billing.api.BillableServiceService; -import org.openmrs.module.billing.api.CashPointService; -import org.openmrs.module.billing.api.ItemPriceService; -import org.openmrs.module.billing.api.model.Bill; -import org.openmrs.module.billing.api.model.BillLineItem; -import org.openmrs.module.billing.api.model.BillStatus; -import org.openmrs.module.billing.api.model.BillableService; -import org.openmrs.module.billing.api.model.BillableServiceStatus; -import org.openmrs.module.billing.api.model.CashPoint; -import org.openmrs.module.billing.api.model.CashierItemPrice; -import org.openmrs.module.billing.api.search.BillableServiceSearch; -import org.openmrs.module.stockmanagement.api.StockManagementService; -import org.openmrs.module.stockmanagement.api.model.StockItem; -import org.springframework.aop.MethodBeforeAdvice; - -@Slf4j -public class OrderCreationMethodBeforeAdvice implements MethodBeforeAdvice { - - final OrderService orderService = Context.getOrderService(); - - final BillService billService = Context.getService(BillService.class); - - final StockManagementService stockService = Context.getService(StockManagementService.class); - - final ItemPriceService priceService = Context.getService(ItemPriceService.class); - - final CashPointService cashPointService = Context.getService(CashPointService.class); - - @Override - public void before(Method method, Object[] args, Object target) { - try { - // Extract the Order object from the arguments - if (method.getName().equals("saveOrder") && args.length > 0 && args[0] instanceof Order) { - Order order = (Order) args[0]; - if (!fetchPatientPayingCategory(order)) { - return; - } - - // Check if the order already exists by looking at the database - if (orderService.getOrderByUuid(order.getUuid()) == null) { - // This is a new order - Patient patient = order.getPatient(); - String cashierUUID = Context.getAuthenticatedUser().getUuid(); - if (order instanceof DrugOrder) { - DrugOrder drugOrder = (DrugOrder) order; - Integer drugID = drugOrder.getDrug() != null ? drugOrder.getDrug().getDrugId() : 0; - double drugQuantity = drugOrder.getQuantity() != null ? drugOrder.getQuantity() : 0.0; - List stockItems = stockService.getStockItemByDrug(drugID); - if (!stockItems.isEmpty()) { - addBillItemToBill(order, patient, cashierUUID, stockItems.get(0), null, (int) drugQuantity, - order.getDateActivated()); - } - } else if (order instanceof TestOrder) { - TestOrder testOrder = (TestOrder) order; - BillableServiceSearch searchTemplate = new BillableServiceSearch(); - searchTemplate.setConceptUuid(testOrder.getConcept().getUuid()); - searchTemplate.setServiceStatus(BillableServiceStatus.ENABLED); - - BillableServiceService service = Context.getService(BillableServiceService.class); - List searchResult = service.getBillableServices(searchTemplate, null); - if (!searchResult.isEmpty()) { - addBillItemToBill(order, patient, cashierUUID, null, searchResult.get(0), 1, - order.getDateActivated()); - - } - } - } - } - } - catch (Exception e) { - log.error(e.getMessage(), e); - } - } - - /** - * Adds a bill item to the cashier module - * - * @param patient - * @param cashierUUID - */ - public void addBillItemToBill(Order order, Patient patient, String cashierUUID, StockItem stockitem, - BillableService service, Integer quantity, Date orderDate) { - try { - // Search for a bill - Bill activeBill = new Bill(); - activeBill.setPatient(patient); - activeBill.setStatus(BillStatus.PENDING); - - // Bill Item - BillLineItem billLineItem = new BillLineItem(); - List itemPrices = new ArrayList<>(); - if (stockitem != null) { - billLineItem.setItem(stockitem); - itemPrices = priceService.getItemPrice(stockitem); - } else if (service != null) { - billLineItem.setBillableService(service); - itemPrices = priceService.getServicePrice(service); - } - - if (!itemPrices.isEmpty()) { - List matchingPrices = itemPrices.stream() - .filter(p -> p.getPaymentMode().getUuid().equals(fetchPatientPayment(order))) - .collect(Collectors.toList()); - billLineItem.setPrice( - matchingPrices.isEmpty() ? itemPrices.get(0).getPrice() : matchingPrices.get(0).getPrice()); - } else { - billLineItem.setPrice(new BigDecimal("0.0")); - } - billLineItem.setQuantity(quantity); - billLineItem.setPaymentStatus(BillStatus.PENDING); - billLineItem.setLineItemOrder(0); - - // Bill - User user = Context.getAuthenticatedUser(); - List providers = new ArrayList<>(Context.getProviderService().getProvidersByPerson(user.getPerson())); - - if (!providers.isEmpty()) { - activeBill.setCashier(providers.get(0)); - List cashPoints = cashPointService.getAllCashPoints(false); - activeBill.setCashPoint(cashPoints.get(0)); - activeBill.addLineItem(billLineItem); - activeBill.setStatus(BillStatus.PENDING); - billService.saveBill(activeBill); - } - - } - catch (Exception ex) { - log.error(ex.getMessage(), ex); - } - } - - private String fetchPatientPayment(Order order) { - String patientPayingMethod = ""; - Collection visitAttributeList = order.getEncounter().getVisit().getActiveAttributes(); - - for (VisitAttribute attribute : visitAttributeList) { - if (attribute.getAttributeType().getUuid().equals("c39b684c-250f-4781-a157-d6ad7353bc90") - && !attribute.getVoided()) { - patientPayingMethod = attribute.getValueReference(); - } - } - return patientPayingMethod; - } - - private boolean fetchPatientPayingCategory(Order order) { - boolean isPaying = false; - Collection visitAttributeList = order.getEncounter().getVisit().getActiveAttributes(); - - for (VisitAttribute attribute : visitAttributeList) { - if (attribute.getAttributeType().getUuid().equals("caf2124f-00a9-4620-a250-efd8535afd6d") - && attribute.getValueReference().equals("1c30ee58-82d4-4ea4-a8c1-4bf2f9dfc8cf")) { - return true; - } - } - - return isPaying; - } -} diff --git a/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java b/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java index 3a8fd343..030960fb 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java +++ b/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java @@ -13,6 +13,7 @@ */ package org.openmrs.module.billing.api; +import org.openmrs.Order; import org.openmrs.api.OpenmrsService; import org.openmrs.module.billing.api.model.BillLineItem; @@ -38,4 +39,13 @@ public interface BillLineItemService extends OpenmrsService { @Nullable BillLineItem getBillLineItemByUuid(String uuid); + /** + * Retrieves the active (non-voided) bill line item linked to the given order. + * + * @param order the order to look up + * @return the bill line item for that order, or null if none exists + */ + @Nullable + BillLineItem getBillLineItemByOrder(Order order); + } diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/BillingEventListener.java b/api/src/main/java/org/openmrs/module/billing/api/billing/BillingEventListener.java new file mode 100644 index 00000000..3ea54330 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/billing/BillingEventListener.java @@ -0,0 +1,46 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing; + +import org.openmrs.OpenmrsObject; +import org.openmrs.event.Event; +import org.openmrs.event.EventListener; +import org.openmrs.module.DaemonToken; + +/** + * Marker interface for event listeners that should be auto-subscribed when the billing module + * starts. Implementations declare which domain class and action they listen to. The module + * activator discovers all registered {@code BillingEventListener} beans, sets the daemon token, and + * subscribes them automatically. + *

+ * To add a new listener, implement this interface and register the bean in + * {@code moduleApplicationContext.xml} — no changes to the activator are needed. + */ +public interface BillingEventListener extends EventListener { + + /** + * The OpenMRS domain class this listener subscribes to (e.g. {@code Order.class}). + */ + Class getSubscribedClass(); + + /** + * The event action this listener subscribes to (e.g. {@link Event.Action#CREATED}). + */ + Event.Action getSubscribedAction(); + + /** + * Set the daemon token for executing operations in a daemon thread context. + */ + void setDaemonToken(DaemonToken daemonToken); +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingEventListener.java b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingEventListener.java new file mode 100644 index 00000000..a9072e45 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingEventListener.java @@ -0,0 +1,104 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing; + +import javax.jms.JMSException; +import javax.jms.MapMessage; +import javax.jms.Message; + +import java.util.List; + +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.openmrs.OpenmrsObject; +import org.openmrs.Order; +import org.openmrs.api.context.Context; +import org.openmrs.api.context.Daemon; +import org.openmrs.event.Event; +import org.openmrs.module.DaemonToken; + +/** + * Listens for Order CREATED events from the OpenMRS Event module and delegates billing to the + * appropriate {@link OrderBillingStrategy}. + */ +@Slf4j +@Setter +public class OrderBillingEventListener implements BillingEventListener { + + private DaemonToken daemonToken; + + @Override + public Class getSubscribedClass() { + return Order.class; + } + + @Override + public Event.Action getSubscribedAction() { + return Event.Action.CREATED; + } + + @Override + public void onMessage(Message message) { + if (daemonToken == null) { + log.error("Cannot process order billing event: daemon token not set"); + return; + } + + Daemon.runInDaemonThread(() -> { + try { + processMessage(message); + } + catch (Exception e) { + log.error("Error processing order billing event", e); + } + }, daemonToken); + } + + private void processMessage(Message message) throws JMSException { + MapMessage mapMessage = (MapMessage) message; + String uuid = mapMessage.getString("uuid"); + String action = mapMessage.getString("action"); + + if (!"CREATED".equals(action)) { + return; + } + + Order order = Context.getOrderService().getOrderByUuid(uuid); + if (order == null) { + log.warn("Order not found for UUID: {}", uuid); + return; + } + + processOrder(order); + } + + /** + * Process a single order through the billing strategy chain. Package-visible so that integration + * tests can invoke the billing pipeline directly without requiring a JMS broker. + * + * @param order a persisted order + */ + void processOrder(Order order) { + List strategies = Context.getRegisteredComponents(OrderBillingStrategy.class); + + for (OrderBillingStrategy strategy : strategies) { + if (strategy.supports(order)) { + strategy.generateBill(order); + return; + } + } + + log.debug("No billing strategy found for order type: {}", order.getClass().getSimpleName()); + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingStrategy.java new file mode 100644 index 00000000..af325d15 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingStrategy.java @@ -0,0 +1,46 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing; + +import org.openmrs.Order; +import org.openmrs.module.billing.api.model.Bill; + +import java.util.Optional; + +/** + * Strategy for generating a bill from an order. Implementations are Spring beans discovered via + * {@code Context.getRegisteredComponents(OrderBillingStrategy.class)}. The first strategy whose + * {@link #supports(Order)} returns {@code true} handles the order. To override a default strategy, + * register a bean with the same name (e.g. "drugOrderBillingStrategy") in your module's Spring + * context. + */ +public interface OrderBillingStrategy { + + /** + * Whether this strategy can handle the given order. + * + * @param order the order to evaluate + * @return true if this strategy should process the order + */ + boolean supports(Order order); + + /** + * Generate and persist a bill for the given order. Implementations should check for duplicates + * (idempotency) before creating a new bill. + * + * @param order a persisted order (guaranteed to exist in the database) + * @return the created bill, or empty if the order should not be billed + */ + Optional generateBill(Order order); +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/impl/AbstractOrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/AbstractOrderBillingStrategy.java new file mode 100644 index 00000000..e948d742 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/AbstractOrderBillingStrategy.java @@ -0,0 +1,191 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing.impl; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; +import org.openmrs.Order; +import org.openmrs.Patient; +import org.openmrs.PatientProgram; +import org.openmrs.Provider; +import org.openmrs.api.ProgramWorkflowService; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.BillExemptionService; +import org.openmrs.module.billing.api.BillLineItemService; +import org.openmrs.module.billing.api.BillService; +import org.openmrs.module.billing.api.CashPointService; +import org.openmrs.module.billing.api.billing.OrderBillingStrategy; +import org.openmrs.module.billing.api.evaluator.ExemptionRuleEngine; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillExemption; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.CashPoint; +import org.openmrs.module.billing.api.model.ExemptionType; + +/** + * Base class for billing strategies that provides shared logic for bill creation, line item + * voiding, and exemption checking. Subclasses implement {@link #handleNewOrder(Order)} to create + * line items specific to their order type. + */ +@Slf4j +public abstract class AbstractOrderBillingStrategy implements OrderBillingStrategy { + + @Override + public Optional generateBill(Order order) { + try { + switch (order.getAction()) { + case NEW: + BillLineItemService itemService = Context.getService(BillLineItemService.class); + BillLineItem existingLineItem = itemService.getBillLineItemByOrder(order); + if (existingLineItem != null) { + log.info("Bill line item already exists for order: {}, skipping duplicate bill creation", + order.getUuid()); + return Optional.of(existingLineItem.getBill()); + } + return handleNewOrder(order); + case REVISE: + return handleRevisedOrder(order); + case DISCONTINUE: + handleDiscontinuedOrder(order); + return Optional.empty(); + default: + return Optional.empty(); + } + } + catch (Exception e) { + log.error("Error processing order (action={}): {}", order.getAction(), e.getMessage(), e); + return Optional.empty(); + } + } + + protected abstract Optional handleNewOrder(Order order); + + private Optional handleRevisedOrder(Order order) { + voidPreviousLineItem(order, "Order revised"); + return handleNewOrder(order); + } + + private void handleDiscontinuedOrder(Order order) { + voidPreviousLineItem(order, "Order discontinued"); + } + + private void voidPreviousLineItem(Order order, String reason) { + Order previousOrder = order.getPreviousOrder(); + if (previousOrder == null) { + log.warn("No previous order found for {} order: {}", order.getAction(), order.getUuid()); + return; + } + + BillLineItemService lineItemService = Context.getService(BillLineItemService.class); + BillLineItem existingLineItem = lineItemService.getBillLineItemByOrder(previousOrder); + if (existingLineItem == null) { + log.warn("No bill line item found for previous order: {}", previousOrder.getUuid()); + return; + } + + existingLineItem.setVoided(true); + existingLineItem.setVoidReason(reason); + existingLineItem.setDateVoided(new Date()); + existingLineItem.setVoidedBy(order.getCreator()); + + BillService billService = Context.getService(BillService.class); + billService.saveBill(existingLineItem.getBill()); + } + + protected Optional saveBill(Patient patient, BillLineItem lineItem, Order order) { + BillService billService = Context.getService(BillService.class); + CashPointService cashPointService = Context.getService(CashPointService.class); + + List providers = new ArrayList<>( + Context.getProviderService().getProvidersByPerson(order.getCreator().getPerson())); + + if (providers.isEmpty()) { + log.error("Order creator is not a provider, cannot create bill for order: {}", order.getUuid()); + return Optional.empty(); + } + + List cashPoints = cashPointService.getAllCashPoints(false); + if (cashPoints.isEmpty()) { + log.error("No cash points configured, cannot create bill for order: {}", order.getUuid()); + return Optional.empty(); + } + + Bill bill = new Bill(); + bill.setPatient(patient); + bill.setStatus(BillStatus.PENDING); + bill.setCashier(providers.get(0)); + bill.setCashPoint(cashPoints.get(0)); + bill.addLineItem(lineItem); + + Bill savedBill = billService.saveBill(bill); + return Optional.of(savedBill); + } + + protected boolean checkIfOrderIsExempted(Order order, ExemptionType exemptionType) { + if (order == null || order.getConcept() == null) { + return false; + } + + BillExemptionService exemptionService = Context.getService(BillExemptionService.class); + List exemptions = exemptionService.getExemptionsByConcept(order.getConcept(), exemptionType, false); + if (exemptions == null || exemptions.isEmpty()) { + return false; + } + + ExemptionRuleEngine ruleEngine = Context.getRegisteredComponent("ruleEngine", ExemptionRuleEngine.class); + Map variables = buildExemptionVariables(order); + + for (BillExemption exemption : exemptions) { + if (ruleEngine.isExemptionApplicable(exemption, variables)) { + return true; + } + } + + return false; + } + + private Map buildExemptionVariables(Order order) { + Map variables = new HashMap<>(); + ProgramWorkflowService workflowService = Context.getProgramWorkflowService(); + + Patient patient = order.getPatient(); + variables.put("patient", patient); + if (patient != null) { + variables.put("patientAge", patient.getAge()); + } + + Map orderData = new HashMap<>(); + orderData.put("uuid", order.getUuid()); + if (order.getConcept() != null) { + orderData.put("conceptId", order.getConcept().getConceptId()); + } + variables.put("order", orderData); + + List programs = workflowService.getPatientPrograms(patient, null, null, null, new Date(), null, + false); + List activePrograms = programs.stream().filter(PatientProgram::getActive) + .map(pp -> pp.getProgram().getName()).collect(Collectors.toList()); + variables.put("activePrograms", activePrograms); + + return variables; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/impl/DrugOrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/DrugOrderBillingStrategy.java new file mode 100644 index 00000000..3dc3c34a --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/DrugOrderBillingStrategy.java @@ -0,0 +1,99 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing.impl; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import lombok.extern.slf4j.Slf4j; +import org.openmrs.DrugOrder; +import org.openmrs.Order; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.ItemPriceService; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.CashierItemPrice; +import org.openmrs.module.billing.api.model.ExemptionType; +import org.openmrs.module.stockmanagement.api.StockManagementService; +import org.openmrs.module.stockmanagement.api.model.StockItem; + +/** + * Default billing strategy for {@link DrugOrder}s. Creates a bill line item based on the stock item + * linked to the ordered drug. + */ +@Slf4j +public class DrugOrderBillingStrategy extends AbstractOrderBillingStrategy { + + @Override + public boolean supports(Order order) { + if (!(order instanceof DrugOrder)) { + return false; + } + Order.Action action = order.getAction(); + return action == Order.Action.NEW || action == Order.Action.REVISE || action == Order.Action.DISCONTINUE; + } + + @Override + protected Optional handleNewOrder(Order order) { + DrugOrder drugOrder = (DrugOrder) order; + + if (drugOrder.getDrug() == null) { + log.warn("DrugOrder {} has no drug set, cannot generate bill", order.getUuid()); + return Optional.empty(); + } + + StockManagementService stockService = Context.getService(StockManagementService.class); + Integer drugId = drugOrder.getDrug().getDrugId(); + List stockItems = stockService.getStockItemByDrug(drugId); + + if (stockItems.isEmpty()) { + log.debug("No stock item found for drug ID: {}", drugId); + return Optional.empty(); + } + + int quantity = (int) (drugOrder.getQuantity() != null ? drugOrder.getQuantity() : 0.0); + boolean isExempted = checkIfOrderIsExempted(order, ExemptionType.COMMODITY); + BillStatus lineItemStatus = isExempted ? BillStatus.EXEMPTED : BillStatus.PENDING; + + StockItem stockItem = stockItems.get(0); + BillLineItem lineItem = createLineItem(stockItem, quantity, lineItemStatus, order); + + return saveBill(order.getPatient(), lineItem, order); + } + + private BillLineItem createLineItem(StockItem stockItem, int quantity, BillStatus lineItemStatus, Order order) { + ItemPriceService priceService = Context.getService(ItemPriceService.class); + + BillLineItem lineItem = new BillLineItem(); + lineItem.setItem(stockItem); + + List itemPrices = priceService.getItemPrice(stockItem); + if (!itemPrices.isEmpty()) { + lineItem.setPrice(itemPrices.get(0).getPrice()); + } else if (stockItem.getPurchasePrice() != null) { + lineItem.setPrice(stockItem.getPurchasePrice()); + } else { + lineItem.setPrice(BigDecimal.ZERO); + } + + lineItem.setQuantity(quantity); + lineItem.setPaymentStatus(lineItemStatus); + lineItem.setLineItemOrder(0); + lineItem.setOrder(order); + + return lineItem; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/impl/TestOrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/TestOrderBillingStrategy.java new file mode 100644 index 00000000..8d1a2a57 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/TestOrderBillingStrategy.java @@ -0,0 +1,95 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing.impl; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import lombok.extern.slf4j.Slf4j; +import org.openmrs.Order; +import org.openmrs.TestOrder; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.BillableServiceService; +import org.openmrs.module.billing.api.ItemPriceService; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.BillableService; +import org.openmrs.module.billing.api.model.BillableServiceStatus; +import org.openmrs.module.billing.api.model.CashierItemPrice; +import org.openmrs.module.billing.api.model.ExemptionType; +import org.openmrs.module.billing.api.search.BillableServiceSearch; + +/** + * Default billing strategy for {@link TestOrder}s. Creates a bill line item based on the billable + * service linked to the test order's concept. + */ +@Slf4j +public class TestOrderBillingStrategy extends AbstractOrderBillingStrategy { + + @Override + public boolean supports(Order order) { + if (!(order instanceof TestOrder)) { + return false; + } + Order.Action action = order.getAction(); + return action == Order.Action.NEW || action == Order.Action.REVISE || action == Order.Action.DISCONTINUE; + } + + @Override + protected Optional handleNewOrder(Order order) { + TestOrder testOrder = (TestOrder) order; + + BillableServiceService serviceService = Context.getService(BillableServiceService.class); + BillableServiceSearch searchTemplate = new BillableServiceSearch(); + searchTemplate.setConceptUuid(testOrder.getConcept().getUuid()); + searchTemplate.setServiceStatus(BillableServiceStatus.ENABLED); + + List searchResult = serviceService.getBillableServices(searchTemplate, null); + if (searchResult.isEmpty()) { + log.debug("No billable service found for concept: {}", testOrder.getConcept().getUuid()); + return Optional.empty(); + } + + boolean isExempted = checkIfOrderIsExempted(order, ExemptionType.SERVICE); + BillStatus lineItemStatus = isExempted ? BillStatus.EXEMPTED : BillStatus.PENDING; + + BillableService billableService = searchResult.get(0); + BillLineItem lineItem = createLineItem(billableService, lineItemStatus, order); + + return saveBill(order.getPatient(), lineItem, order); + } + + private BillLineItem createLineItem(BillableService billableService, BillStatus lineItemStatus, Order order) { + ItemPriceService priceService = Context.getService(ItemPriceService.class); + + BillLineItem lineItem = new BillLineItem(); + lineItem.setBillableService(billableService); + + List itemPrices = priceService.getServicePrice(billableService); + if (!itemPrices.isEmpty()) { + lineItem.setPrice(itemPrices.get(0).getPrice()); + } else { + lineItem.setPrice(BigDecimal.ZERO); + } + + lineItem.setQuantity(1); + lineItem.setPaymentStatus(lineItemStatus); + lineItem.setLineItemOrder(0); + lineItem.setOrder(order); + + return lineItem; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java index fec466fd..58407b59 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java +++ b/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java @@ -1,5 +1,6 @@ package org.openmrs.module.billing.api.db; +import org.openmrs.Order; import org.openmrs.module.billing.api.model.BillLineItem; import javax.annotation.Nonnull; @@ -32,4 +33,13 @@ public interface BillLineItemDAO { @Nullable BillLineItem getBillLineItemByUuid(@Nonnull String uuid); + /** + * Retrieves the active (non-voided) bill line item linked to the given order. + * + * @param order the order to look up + * @return the bill line item for that order, or null if none exists + */ + @Nullable + BillLineItem getBillLineItemByOrder(@Nonnull Order order); + } diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java index 51754ba6..ae3a81de 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java +++ b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java @@ -2,6 +2,7 @@ import lombok.AllArgsConstructor; import org.hibernate.SessionFactory; +import org.openmrs.Order; import org.openmrs.module.billing.api.db.BillLineItemDAO; import org.openmrs.module.billing.api.model.BillLineItem; @@ -35,4 +36,13 @@ public BillLineItem getBillLineItemByUuid(@Nonnull String uuid) { return query.getResultStream().findFirst().orElse(null); } + @Override + @Nullable + public BillLineItem getBillLineItemByOrder(@Nonnull Order order) { + TypedQuery query = sessionFactory.getCurrentSession() + .createQuery("select b from BillLineItem b where b.order = :order and b.voided = false", BillLineItem.class); + query.setParameter("order", order); + return query.getResultStream().findFirst().orElse(null); + } + } diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java index 51280316..2f871175 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java +++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java @@ -18,6 +18,7 @@ import lombok.Setter; import org.apache.commons.lang3.StringUtils; +import org.openmrs.Order; import org.openmrs.api.impl.BaseOpenmrsService; import org.openmrs.module.billing.api.BillLineItemService; import org.openmrs.module.billing.api.db.BillLineItemDAO; @@ -46,4 +47,13 @@ public BillLineItem getBillLineItemByUuid(String uuid) { } return billLineItemDAO.getBillLineItemByUuid(uuid); } + + @Override + @Transactional(readOnly = true) + public BillLineItem getBillLineItemByOrder(Order order) { + if (order == null) { + return null; + } + return billLineItemDAO.getBillLineItemByOrder(order); + } } diff --git a/api/src/main/resources/Bill.hbm.xml b/api/src/main/resources/Bill.hbm.xml index dd88092c..f83b3db7 100644 --- a/api/src/main/resources/Bill.hbm.xml +++ b/api/src/main/resources/Bill.hbm.xml @@ -157,6 +157,7 @@ + diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index 1533bb67..85f33487 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -260,6 +260,16 @@ + + + + + + + diff --git a/api/src/test/java/org/openmrs/module/billing/api/billing/OrderBillingEventListenerTest.java b/api/src/test/java/org/openmrs/module/billing/api/billing/OrderBillingEventListenerTest.java new file mode 100644 index 00000000..b607767c --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/billing/OrderBillingEventListenerTest.java @@ -0,0 +1,386 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.billing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmrs.Concept; +import org.openmrs.Drug; +import org.openmrs.DrugOrder; +import org.openmrs.Encounter; +import org.openmrs.Order; +import org.openmrs.Patient; +import org.openmrs.TestOrder; +import org.openmrs.api.ConceptService; +import org.openmrs.api.EncounterService; +import org.openmrs.api.OrderService; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.TestConstants; +import org.openmrs.module.billing.api.BillLineItemService; +import org.openmrs.module.billing.api.BillService; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; + +public class OrderBillingEventListenerTest extends BaseModuleContextSensitiveTest { + + private BillService billService; + + private BillLineItemService lineItemService; + + private OrderService orderService; + + private ConceptService conceptService; + + private EncounterService encounterService; + + private OrderBillingEventListener listener; + + @BeforeEach + public void setup() { + billService = Context.getService(BillService.class); + lineItemService = Context.getService(BillLineItemService.class); + orderService = Context.getOrderService(); + conceptService = Context.getConceptService(); + encounterService = Context.getEncounterService(); + listener = Context.getRegisteredComponent("orderBillingEventListener", OrderBillingEventListener.class); + + executeDataSet(TestConstants.CORE_DATASET2); + executeDataSet(TestConstants.BASE_DATASET_DIR + "StockOperationType.xml"); + executeDataSet(TestConstants.BASE_DATASET_DIR + "CashPointTest.xml"); + executeDataSet(TestConstants.BASE_DATASET_DIR + "OrderBillingTest.xml"); + } + + @Test + public void shouldCreateBillWhenTestOrderIsSaved() { + Concept testConcept = conceptService.getConcept(5497); + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + + // 1. Save a test order + Order savedOrder = saveNewTestOrder(patient, testConcept, encounter); + + // 2. Process through the billing pipeline + listener.processOrder(savedOrder); + Context.flushSession(); + + // 3. Verify a bill was created + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertNotNull(bills); + assertFalse(bills.isEmpty(), "A bill should have been created for the patient"); + + Bill bill = bills.get(0); + assertEquals(BillStatus.PENDING, bill.getStatus()); + assertEquals(patient.getId(), bill.getPatient().getId()); + + // 4. Verify the line item + assertFalse(bill.getLineItems().isEmpty()); + BillLineItem lineItem = bill.getLineItems().get(0); + assertNotNull(lineItem.getBillableService()); + assertEquals(BillStatus.PENDING, lineItem.getPaymentStatus()); + assertEquals(1, lineItem.getQuantity()); + assertEquals(new BigDecimal("75.00"), lineItem.getPrice()); + assertEquals(savedOrder.getId(), lineItem.getOrder().getId()); + } + + @Test + public void shouldNotCreateBillWhenNoBillableServiceMatchesConcept() { + Concept unmappedConcept = conceptService.getConcept(5089); + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + + Order savedOrder = saveNewTestOrder(patient, unmappedConcept, encounter); + + listener.processOrder(savedOrder); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertTrue(bills == null || bills.isEmpty(), + "No bill should be created when no billable service matches the concept"); + } + + @Test + public void shouldVoidLineItemWhenOrderIsDiscontinued() { + Concept testConcept = conceptService.getConcept(5497); + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + + // 1. Save and bill the original order + Order originalOrder = saveNewTestOrder(patient, testConcept, encounter); + listener.processOrder(originalOrder); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertFalse(bills.isEmpty()); + BillLineItem originalLineItem = bills.get(0).getLineItems().get(0); + assertFalse(originalLineItem.getVoided()); + + // 2. Discontinue the order + TestOrder discontinueOrder = new TestOrder(); + discontinueOrder.setPatient(patient); + discontinueOrder.setConcept(testConcept); + discontinueOrder.setEncounter(encounter); + discontinueOrder.setOrderer(Context.getProviderService().getProvider(1)); + discontinueOrder.setCareSetting(orderService.getCareSetting(1)); + discontinueOrder.setOrderType(orderService.getOrderType(2)); + discontinueOrder.setAction(Order.Action.DISCONTINUE); + discontinueOrder.setPreviousOrder(originalOrder); + discontinueOrder.setDateActivated(new Date()); + + Order savedDiscontinue = orderService.saveOrder(discontinueOrder, null); + Context.flushSession(); + + // 3. Process the DISCONTINUE order + listener.processOrder(savedDiscontinue); + Context.flushSession(); + Context.clearSession(); + + // 4. Verify the original line item is voided + BillLineItem reloaded = lineItemService.getBillLineItemByUuid(originalLineItem.getUuid()); + assertNotNull(reloaded); + assertTrue(reloaded.getVoided(), "Line item should be voided after order is discontinued"); + assertEquals("Order discontinued", reloaded.getVoidReason()); + } + + @Test + public void shouldVoidOldAndCreateNewLineItemWhenOrderIsRevised() { + Concept testConcept = conceptService.getConcept(5497); + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + + // 1. Save and bill the original order + Order originalOrder = saveNewTestOrder(patient, testConcept, encounter); + listener.processOrder(originalOrder); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertFalse(bills.isEmpty()); + String originalLineItemUuid = bills.get(0).getLineItems().get(0).getUuid(); + + // 2. Revise the order + TestOrder reviseOrder = new TestOrder(); + reviseOrder.setPatient(patient); + reviseOrder.setConcept(testConcept); + reviseOrder.setEncounter(encounter); + reviseOrder.setOrderer(Context.getProviderService().getProvider(1)); + reviseOrder.setCareSetting(orderService.getCareSetting(1)); + reviseOrder.setOrderType(orderService.getOrderType(2)); + reviseOrder.setAction(Order.Action.REVISE); + reviseOrder.setPreviousOrder(originalOrder); + reviseOrder.setDateActivated(new Date()); + + Order savedRevise = orderService.saveOrder(reviseOrder, null); + Context.flushSession(); + + // 3. Process the REVISE order + listener.processOrder(savedRevise); + Context.flushSession(); + Context.clearSession(); + + // 4. Verify the original line item is voided + BillLineItem voidedLineItem = lineItemService.getBillLineItemByUuid(originalLineItemUuid); + assertNotNull(voidedLineItem); + assertTrue(voidedLineItem.getVoided(), "Original line item should be voided after revision"); + assertEquals("Order revised", voidedLineItem.getVoidReason()); + + // 5. Verify a new bill was created with a new line item + List updatedBills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertTrue(updatedBills.size() >= 2, "A new bill should be created for the revised order"); + + Bill newBill = updatedBills.stream().filter(b -> !b.getId().equals(bills.get(0).getId())).findFirst().orElse(null); + assertNotNull(newBill); + assertFalse(newBill.getLineItems().isEmpty()); + + BillLineItem newLineItem = newBill.getLineItems().get(0); + assertFalse(newLineItem.getVoided()); + assertEquals(savedRevise.getId(), newLineItem.getOrder().getId()); + assertEquals(new BigDecimal("75.00"), newLineItem.getPrice()); + } + + @Test + public void shouldHandleDiscontinueGracefullyWhenNoOriginalBillExists() { + Concept testConcept = conceptService.getConcept(5497); + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + + // Save a NEW order but do NOT process it through billing + Order originalOrder = saveNewTestOrder(patient, testConcept, encounter); + + TestOrder discontinueOrder = new TestOrder(); + discontinueOrder.setPatient(patient); + discontinueOrder.setConcept(testConcept); + discontinueOrder.setEncounter(encounter); + discontinueOrder.setOrderer(Context.getProviderService().getProvider(1)); + discontinueOrder.setCareSetting(orderService.getCareSetting(1)); + discontinueOrder.setOrderType(orderService.getOrderType(2)); + discontinueOrder.setAction(Order.Action.DISCONTINUE); + discontinueOrder.setPreviousOrder(originalOrder); + discontinueOrder.setDateActivated(new Date()); + + Order savedDiscontinue = orderService.saveOrder(discontinueOrder, null); + Context.flushSession(); + + // Should not throw — just log a warning + listener.processOrder(savedDiscontinue); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertTrue(bills == null || bills.isEmpty(), "No bill should exist"); + } + + @Test + public void strategyLookup_shouldFindRegisteredStrategies() { + List strategies = Context.getRegisteredComponents(OrderBillingStrategy.class); + assertNotNull(strategies); + assertFalse(strategies.isEmpty()); + + boolean hasDrugStrategy = false; + boolean hasTestStrategy = false; + for (OrderBillingStrategy strategy : strategies) { + if (strategy.getClass().getSimpleName().equals("DrugOrderBillingStrategy")) { + hasDrugStrategy = true; + } + if (strategy.getClass().getSimpleName().equals("TestOrderBillingStrategy")) { + hasTestStrategy = true; + } + } + assertTrue(hasDrugStrategy, "DrugOrderBillingStrategy should be registered"); + assertTrue(hasTestStrategy, "TestOrderBillingStrategy should be registered"); + } + + @Test + public void shouldCreateBillWhenDrugOrderIsSaved() { + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + Drug drug = Context.getConceptService().getDrug(2); // Triomune-30, linked to stock_item_id=100 + + DrugOrder drugOrder = new DrugOrder(); + drugOrder.setPatient(patient); + drugOrder.setDrug(drug); + drugOrder.setConcept(drug.getConcept()); + drugOrder.setEncounter(encounter); + drugOrder.setOrderer(Context.getProviderService().getProvider(1)); + drugOrder.setCareSetting(orderService.getCareSetting(1)); + drugOrder.setOrderType(orderService.getOrderType(1)); + drugOrder.setDateActivated(new Date()); + drugOrder.setQuantity(5.0); + drugOrder.setQuantityUnits(conceptService.getConcept(51)); + drugOrder.setDose(1.0); + drugOrder.setDoseUnits(conceptService.getConcept(51)); + drugOrder.setRoute(conceptService.getConcept(22)); + drugOrder.setFrequency(orderService.getOrderFrequency(1)); + drugOrder.setDosingType(org.openmrs.SimpleDosingInstructions.class); + drugOrder.setNumRefills(0); + + Order savedOrder = orderService.saveOrder(drugOrder, null); + assertNotNull(savedOrder.getId()); + Context.flushSession(); + + listener.processOrder(savedOrder); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertNotNull(bills); + assertFalse(bills.isEmpty(), "A bill should have been created for the drug order"); + + Bill bill = bills.get(0); + assertEquals(BillStatus.PENDING, bill.getStatus()); + + BillLineItem lineItem = bill.getLineItems().get(0); + assertNotNull(lineItem.getItem(), "Line item should reference a stock item"); + assertEquals(BillStatus.PENDING, lineItem.getPaymentStatus()); + assertEquals(5, lineItem.getQuantity()); + assertEquals(new BigDecimal("150.00"), lineItem.getPrice()); + assertEquals(savedOrder.getId(), lineItem.getOrder().getId()); + } + + @Test + public void shouldNotCreateBillForDrugOrderWhenNoStockItemExists() { + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + Drug drug = Context.getConceptService().getDrug(3); // Aspirin — no stock item in test data + + DrugOrder drugOrder = new DrugOrder(); + drugOrder.setPatient(patient); + drugOrder.setDrug(drug); + drugOrder.setConcept(drug.getConcept()); + drugOrder.setEncounter(encounter); + drugOrder.setOrderer(Context.getProviderService().getProvider(1)); + drugOrder.setCareSetting(orderService.getCareSetting(2)); + drugOrder.setOrderType(orderService.getOrderType(1)); + drugOrder.setDateActivated(new Date()); + drugOrder.setQuantity(2.0); + drugOrder.setQuantityUnits(conceptService.getConcept(51)); + drugOrder.setDose(1.0); + drugOrder.setDoseUnits(conceptService.getConcept(51)); + drugOrder.setRoute(conceptService.getConcept(22)); + drugOrder.setFrequency(orderService.getOrderFrequency(1)); + drugOrder.setDosingType(org.openmrs.SimpleDosingInstructions.class); + drugOrder.setNumRefills(0); + + Order savedOrder = orderService.saveOrder(drugOrder, null); + Context.flushSession(); + + listener.processOrder(savedOrder); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertTrue(bills == null || bills.isEmpty(), "No bill should be created when no stock item matches the drug"); + } + + @Test + public void shouldNotCreateDuplicateBillForSameOrder() { + Concept testConcept = conceptService.getConcept(5497); + Encounter encounter = encounterService.getEncounter(3); + Patient patient = encounter.getPatient(); + + Order savedOrder = saveNewTestOrder(patient, testConcept, encounter); + + // Process the same order twice + listener.processOrder(savedOrder); + Context.flushSession(); + listener.processOrder(savedOrder); + Context.flushSession(); + + List bills = billService.getBillsByPatientUuid(patient.getUuid(), null); + assertNotNull(bills); + assertEquals(1, bills.size(), "Only one bill should exist — second call should be idempotent"); + } + + private Order saveNewTestOrder(Patient patient, Concept concept, Encounter encounter) { + TestOrder testOrder = new TestOrder(); + testOrder.setPatient(patient); + testOrder.setConcept(concept); + testOrder.setEncounter(encounter); + testOrder.setOrderer(Context.getProviderService().getProvider(1)); + testOrder.setCareSetting(orderService.getCareSetting(1)); + testOrder.setOrderType(orderService.getOrderType(2)); + testOrder.setDateActivated(new Date()); + + Order savedOrder = orderService.saveOrder(testOrder, null); + assertNotNull(savedOrder.getId()); + Context.flushSession(); + return savedOrder; + } +} diff --git a/api/src/test/resources/org/openmrs/module/billing/api/include/OrderBillingTest.xml b/api/src/test/resources/org/openmrs/module/billing/api/include/OrderBillingTest.xml new file mode 100644 index 00000000..c945a666 --- /dev/null +++ b/api/src/test/resources/org/openmrs/module/billing/api/include/OrderBillingTest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fhir/pom.xml b/fhir/pom.xml index fe23d85d..74289688 100644 --- a/fhir/pom.xml +++ b/fhir/pom.xml @@ -73,6 +73,10 @@ stockmanagement-api jar + + org.openmrs + event-api + org.projectlombok lombok diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index 1e69ec34..b7ff0c4b 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -28,21 +28,12 @@ org.openmrs.module.idgen org.openmrs.module.stockmanagement org.openmrs.module.fhir2 + org.openmrs.event org.openmrs.module.billing.BillingModuleActivator - - org.openmrs.api.OrderService - ${project.parent.groupId}.${project.parent.artifactId}.advice.GenerateBillFromOrderAdvice - - - - org.openmrs.api.OrderService - ${project.parent.groupId}.${project.parent.artifactId}.advice.OrderCreationMethodBeforeAdvice - - ${project.parent.artifactId}.defaultReceiptReportId ID of the default Jasper report to use for generating a receipt on the Bill page diff --git a/pom.xml b/pom.xml index 58af7a24..48fb580f 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,7 @@ 8.0.2 1.4.0 2.4.0 + 4.0.0 1.18.38 1.17.6 @@ -184,6 +185,13 @@ provided + + org.openmrs + event-api + ${eventVersion} + provided + + org.reflections reflections