From d35e723286b14c6a8072f2c2a599932f4102476d Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 01:46:01 +0300 Subject: [PATCH 1/9] Index Bill and BillRefund into querystore via the SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements org.openmrs.module.querystore.spi.ResourceTypeProvider for two billing resource types — billing_bill and billing_bill_refund — producing the cross-cutting QueryDocument fields (patient_uuid, resource_uuid, date, last_modified, text) plus type-specific metadata (status, totals, balance, cashier, cash point, visit; refund amount, approver/completer, dates). Re-indexing is wired through AbstractIndexingAdvice subclasses on BillService and BillRefundService — each save/void/unvoid/purge call projects the entity through the embed-then-upsert pipeline after commit. BillDiscountServiceImpl.saveBillDiscount additionally re-saves the parent Bill so amount_after_discount (denormalized into the bill document) stays in sync after discount mutations; the BillService advice picks up the resulting save. BillLineItem and Payment are intentionally not contributed as standalone types — neither has a service save method (line items cascade-save from Bill, payments have no service at all), so AOP can't trigger on their mutations. Their state is summarized inside the Bill document instead. Balance is derived from getAmountAfterDiscount, not gross total, so a bill whose effective amount is fully covered by payments reports a zero/negative balance correctly. Querystore is declared require_module rather than aware_of, because the advice and provider classes statically reference querystore-api types; without querystore on the classpath the bean classes fail to load and Spring context init fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/pom.xml | 5 + .../api/impl/BillDiscountServiceImpl.java | 36 +++++++- .../api/querystore/BillIndexingAdvice.java | 47 ++++++++++ .../api/querystore/BillRecordSerializer.java | 91 +++++++++++++++++++ .../querystore/BillRefundIndexingAdvice.java | 47 ++++++++++ .../BillRefundRecordSerializer.java | 91 +++++++++++++++++++ .../BillRefundResourceTypeProvider.java | 38 ++++++++ .../querystore/BillResourceTypeProvider.java | 41 +++++++++ .../BillingQueryStoreConstants.java | 65 +++++++++++++ .../resources/moduleApplicationContext.xml | 20 ++++ .../api/impl/BillDiscountServiceImplTest.java | 28 ++++++ omod/src/main/resources/config.xml | 11 +++ pom.xml | 11 +++ 13 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java diff --git a/api/pom.xml b/api/pom.xml index 15d5f5de..3a7b64da 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -53,6 +53,11 @@ event-api + + org.openmrs.module + querystore-api + + org.openmrs.module uiframework-api diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java index 9f769570..a564b3a4 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java +++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java @@ -9,15 +9,22 @@ */ package org.openmrs.module.billing.api.impl; +import java.util.Date; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.openmrs.api.context.Context; import org.openmrs.module.billing.api.BillDiscountService; +import org.openmrs.module.billing.api.BillService; import org.openmrs.module.billing.api.db.BillDiscountDAO; +import org.openmrs.module.billing.api.model.Bill; import org.openmrs.module.billing.api.model.BillDiscount; import org.openmrs.module.billing.api.model.DiscountStatus; +import org.openmrs.module.billing.api.util.PrivilegeConstants; import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor public class BillDiscountServiceImpl implements BillDiscountService { @@ -56,7 +63,9 @@ public List getDiscountsByBillId(Integer billId) { @Override @Transactional public BillDiscount saveBillDiscount(BillDiscount billDiscount) { - return billDiscountDAO.saveBillDiscount(billDiscount); + BillDiscount saved = billDiscountDAO.saveBillDiscount(billDiscount); + touchParentBill(saved); + return saved; } @Override @@ -64,4 +73,29 @@ public BillDiscount saveBillDiscount(BillDiscount billDiscount) { public DiscountStatus getStatusById(Integer id) { return billDiscountDAO.getStatusById(id); } + + // Bill.getAmountAfterDiscount() is derived; a discount mutation changes the bill's effective + // value without touching any bill column, so the parent row stays clean. Re-save to advance + // dateChanged — the querystore BillIndexingAdvice fires on the resulting BillService.saveBill. + private void touchParentBill(BillDiscount discount) { + Integer billId = discount.getBill() == null ? null : discount.getBill().getId(); + if (billId == null) { + log.error("Saved discount {} has no associated bill; skipping parent bill touch", discount.getUuid()); + return; + } + try { + Context.addProxyPrivilege(PrivilegeConstants.MANAGE_BILLS); + Bill freshBill = Context.getService(BillService.class).getBill(billId); + if (freshBill == null) { + log.error("Discount {} references bill {} which could not be loaded; parent bill not touched", + discount.getUuid(), billId); + return; + } + freshBill.setDateChanged(new Date()); + Context.getService(BillService.class).saveBill(freshBill); + } + finally { + Context.removeProxyPrivilege(PrivilegeConstants.MANAGE_BILLS); + } + } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java new file mode 100644 index 00000000..4dd0bc55 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice; + +public class BillIndexingAdvice extends AbstractIndexingAdvice { + + static final Set TRIGGER_METHODS = new HashSet<>( + Arrays.asList("saveBill", "voidBill", "unvoidBill", "purgeBill")); + + static final Set PURGE_METHODS = Collections.singleton("purgeBill"); + + @Override + protected Class getSupportedType() { + return Bill.class; + } + + @Override + protected BillRecordSerializer serializer() { + return Context.getRegisteredComponent("billing.querystore.serializer.bill", BillRecordSerializer.class); + } + + @Override + protected Set triggerMethods() { + return TRIGGER_METHODS; + } + + @Override + protected Set purgeMethods() { + return PURGE_METHODS; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java new file mode 100644 index 00000000..83362215 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java @@ -0,0 +1,91 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import org.openmrs.Patient; +import org.openmrs.Visit; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.querystore.model.QueryDocument; +import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; +import org.openmrs.module.querystore.util.DateFormatUtil; + +public class BillRecordSerializer extends AbstractRecordSerializer { + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_BILL; + } + + @Override + public Class getSupportedType() { + return Bill.class; + } + + @Override + protected String getPatientUuid(Bill bill) { + Patient patient = bill.getPatient(); + return patient != null ? patient.getUuid() : null; + } + + @Override + protected String getResourceUuid(Bill bill) { + return bill.getUuid(); + } + + @Override + protected LocalDate getDate(Bill bill) { + return DateFormatUtil.toLocalDate(bill.getDateCreated()); + } + + @Override + protected void populate(Bill bill, QueryDocument doc) { + if (bill.getPatient() == null) { + return; + } + + BillStatus status = bill.getStatus(); + BigDecimal total = bill.getTotal(); + BigDecimal amountAfterDiscount = bill.getAmountAfterDiscount(); + BigDecimal totalPaid = bill.getTotalPayments(); + // Balance must be derived from amountAfterDiscount, not total — a bill whose only discount + // brings the effective amount to ≤ totalPayments has a zero/negative balance, and using + // gross total here would over-state what's still owed. + BigDecimal balance = amountAfterDiscount.subtract(totalPaid); + + String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid(); + doc.setText(String.format("Bill %s. Status: %s. Total: %s. Paid: %s. Balance: %s.", receiptOrUuid, + status != null ? status.name() : "UNKNOWN", total.toPlainString(), totalPaid.toPlainString(), + balance.toPlainString())); + + doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null); + doc.putMetadata(BillingQueryStoreConstants.FIELD_TOTAL, total); + doc.putMetadata(BillingQueryStoreConstants.FIELD_AMOUNT_AFTER_DISCOUNT, amountAfterDiscount); + doc.putMetadata(BillingQueryStoreConstants.FIELD_TOTAL_PAID, totalPaid); + doc.putMetadata(BillingQueryStoreConstants.FIELD_BALANCE, balance); + doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, bill.getVoided()); + + if (bill.getCashier() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_UUID, bill.getCashier().getUuid()); + } + if (bill.getCashPoint() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID, bill.getCashPoint().getUuid()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME, bill.getCashPoint().getName()); + } + Visit visit = bill.getVisit(); + if (visit != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_VISIT_UUID, visit.getUuid()); + } + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java new file mode 100644 index 00000000..ed7a8993 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.model.BillRefund; +import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice; + +public class BillRefundIndexingAdvice extends AbstractIndexingAdvice { + + static final Set TRIGGER_METHODS = new HashSet<>( + Arrays.asList("saveBillRefund", "voidBillRefund", "unvoidBillRefund", "purgeBillRefund")); + + static final Set PURGE_METHODS = Collections.singleton("purgeBillRefund"); + + @Override + protected Class getSupportedType() { + return BillRefund.class; + } + + @Override + protected BillRefundRecordSerializer serializer() { + return Context.getRegisteredComponent("billing.querystore.serializer.bill_refund", BillRefundRecordSerializer.class); + } + + @Override + protected Set triggerMethods() { + return TRIGGER_METHODS; + } + + @Override + protected Set purgeMethods() { + return PURGE_METHODS; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java new file mode 100644 index 00000000..2b4db8fb --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java @@ -0,0 +1,91 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.time.LocalDate; + +import org.openmrs.Patient; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillRefund; +import org.openmrs.module.billing.api.model.RefundStatus; +import org.openmrs.module.querystore.model.QueryDocument; +import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; +import org.openmrs.module.querystore.util.DateFormatUtil; + +public class BillRefundRecordSerializer extends AbstractRecordSerializer { + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_REFUND; + } + + @Override + public Class getSupportedType() { + return BillRefund.class; + } + + @Override + protected String getPatientUuid(BillRefund refund) { + Bill bill = refund.getBill(); + if (bill == null) { + return null; + } + Patient patient = bill.getPatient(); + return patient != null ? patient.getUuid() : null; + } + + @Override + protected String getResourceUuid(BillRefund refund) { + return refund.getUuid(); + } + + @Override + protected LocalDate getDate(BillRefund refund) { + return DateFormatUtil.toLocalDate(refund.getDateCreated()); + } + + @Override + protected void populate(BillRefund refund, QueryDocument doc) { + Bill bill = refund.getBill(); + if (bill == null || bill.getPatient() == null) { + return; + } + + RefundStatus status = refund.getStatus(); + String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid(); + doc.setText(String.format("Refund of %s for bill %s. Status: %s. Reason: %s.", + refund.getRefundAmount().toPlainString(), receiptOrUuid, status != null ? status.name() : "UNKNOWN", + refund.getReason() != null ? refund.getReason() : "")); + + doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_UUID, bill.getUuid()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_REFUND_AMOUNT, refund.getRefundAmount()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null); + doc.putMetadata(BillingQueryStoreConstants.FIELD_REASON, refund.getReason()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, refund.getVoided()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_APPROVED, refund.getDateApproved()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_COMPLETED, refund.getDateCompleted()); + + BillLineItem lineItem = refund.getLineItem(); + if (lineItem != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID, lineItem.getUuid()); + } + if (refund.getInitiator() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_INITIATOR_UUID, refund.getInitiator().getUuid()); + } + if (refund.getApprover() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_APPROVER_UUID, refund.getApprover().getUuid()); + } + if (refund.getCompleter() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_COMPLETER_UUID, refund.getCompleter().getUuid()); + } + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java new file mode 100644 index 00000000..6eb9299a --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import org.openmrs.module.querystore.bootstrap.TypeBootstrapper; +import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer; +import org.openmrs.module.querystore.spi.ResourceTypeProvider; + +public class BillRefundResourceTypeProvider implements ResourceTypeProvider { + + private final BillRefundRecordSerializer serializer; + + public BillRefundResourceTypeProvider(BillRefundRecordSerializer serializer) { + this.serializer = serializer; + } + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_REFUND; + } + + @Override + public ClinicalRecordSerializer getSerializer() { + return serializer; + } + + @Override + public TypeBootstrapper getBootstrapper() { + return null; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java new file mode 100644 index 00000000..05a4292f --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import org.openmrs.module.querystore.bootstrap.TypeBootstrapper; +import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer; +import org.openmrs.module.querystore.spi.ResourceTypeProvider; + +public class BillResourceTypeProvider implements ResourceTypeProvider { + + private final BillRecordSerializer serializer; + + public BillResourceTypeProvider(BillRecordSerializer serializer) { + this.serializer = serializer; + } + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_BILL; + } + + @Override + public ClinicalRecordSerializer getSerializer() { + return serializer; + } + + @Override + public TypeBootstrapper getBootstrapper() { + // No historical-record bootstrap for this v1; the AOP advice on BillService projects + // ongoing mutations, and any pre-existing bills will not appear in the index until a + // bootstrap path lands (separate slice). + return null; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java new file mode 100644 index 00000000..b5584ee0 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +// Resource-type names and document field names that appear in BOTH the serializer (QueryDocument +// metadata writes) AND any future consumer (querystore queries, dashboards, integrations). A typo +// on either side of that boundary silently breaks indexing of the renamed field — the schema is +// self-healing, so a misspelled key just creates a parallel column nobody queries against. +// Per the ADR, field names are part of the public contract; a rename is a re-index event. +final class BillingQueryStoreConstants { + + static final String RESOURCE_TYPE_BILL = "billing_bill"; + + static final String RESOURCE_TYPE_BILL_REFUND = "billing_bill_refund"; + + static final String FIELD_RECEIPT_NUMBER = "receipt_number"; + + static final String FIELD_BILL_UUID = "bill_uuid"; + + static final String FIELD_STATUS = "status"; + + static final String FIELD_VOIDED = "voided"; + + static final String FIELD_TOTAL = "total"; + + static final String FIELD_AMOUNT_AFTER_DISCOUNT = "amount_after_discount"; + + static final String FIELD_TOTAL_PAID = "total_paid"; + + static final String FIELD_BALANCE = "balance"; + + static final String FIELD_CASHIER_UUID = "cashier_uuid"; + + static final String FIELD_CASH_POINT_UUID = "cash_point_uuid"; + + static final String FIELD_CASH_POINT_NAME = "cash_point_name"; + + static final String FIELD_VISIT_UUID = "visit_uuid"; + + static final String FIELD_REFUND_AMOUNT = "refund_amount"; + + static final String FIELD_REASON = "reason"; + + static final String FIELD_DATE_APPROVED = "date_approved"; + + static final String FIELD_DATE_COMPLETED = "date_completed"; + + static final String FIELD_BILL_LINE_ITEM_UUID = "bill_line_item_uuid"; + + static final String FIELD_INITIATOR_UUID = "initiator_uuid"; + + static final String FIELD_APPROVER_UUID = "approver_uuid"; + + static final String FIELD_COMPLETER_UUID = "completer_uuid"; + + private BillingQueryStoreConstants() { + } +} diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index f309b94d..dc0a2325 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -337,6 +337,26 @@ + + + + + + + + + + + + + diff --git a/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java b/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java index c68adad9..63cedaeb 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java @@ -25,6 +25,9 @@ import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -333,6 +336,31 @@ public void saveBillDiscount_shouldAllowReSavingExistingDiscountWithoutFalsePosi assertEquals("Updated justification", saved.getJustification()); } + @Test + public void saveBillDiscount_shouldAdvanceParentBillDateChanged() { + // amount_after_discount is derived from the discounts collection — a discount mutation + // changes the bill's effective value without touching any bill column. The parent bill + // must be re-saved so dateChanged advances and consumers see the change. + // Truncate to second precision: the DB TIMESTAMP column drops sub-second precision, so a + // ms-precision baseline captured in the same second as the save would fail comparison + // against the truncated reloaded value. + Date beforeSave = Date.from(Instant.now().truncatedTo(ChronoUnit.SECONDS)); + + BillDiscount discount = buildDiscount(POSTED_BILL_UUID, DiscountType.FIXED_AMOUNT, new BigDecimal("50.00"), + new BigDecimal("50.00"), "Test parent-bill touch"); + service.saveBillDiscount(discount); + Context.flushSession(); + Context.clearSession(); + + Bill parentBill = billService.getBillByUuid(POSTED_BILL_UUID); + assertNotNull(parentBill); + Date afterDateChanged = parentBill.getDateChanged(); + assertNotNull(afterDateChanged, "Parent Bill.dateChanged must be set after a discount save"); + assertTrue(afterDateChanged.compareTo(beforeSave) >= 0, + "Parent Bill.dateChanged must be at-or-after the discount save timestamp (was " + afterDateChanged + + ", expected >= " + beforeSave + ")"); + } + @Test public void getDiscountsByBillId_shouldReturnFullAuditHistory() { Bill bill = billService.getBillByUuid(BILL_WITH_ACTIVE_DISCOUNT_UUID); diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index e40c0adb..6603491c 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -40,8 +40,19 @@ org.openmrs.module.stockmanagement org.openmrs.module.fhir2 org.openmrs.event + org.openmrs.module.querystore + + org.openmrs.module.billing.api.BillService + org.openmrs.module.billing.api.querystore.BillIndexingAdvice + + + + org.openmrs.module.billing.api.BillRefundService + org.openmrs.module.billing.api.querystore.BillRefundIndexingAdvice + + org.openmrs.module.billing.BillingModuleActivator diff --git a/pom.xml b/pom.xml index 8d0c85f4..8b9bb9b6 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,7 @@ 1.4.0 2.4.0 4.0.0 + 1.0.0-SNAPSHOT 1.18.38 1.17.6 @@ -154,6 +155,13 @@ provided + + org.openmrs.module + querystore-api + ${querystoreVersion} + provided + + org.reflections reflections @@ -391,6 +399,9 @@ openmrs-repo OpenMRS Public https://mavenrepo.openmrs.org/public + + true + From 903f863c7f0f5f0bc34f4db504738ffc05657851 Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 01:54:53 +0300 Subject: [PATCH 2/9] Make billing-fhir tests load the billing SPI bean context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billing-api now declares serializer / provider beans whose supertypes live in querystore-api (provided scope). Spring's AOP infrastructure resolves every bean's class during context init to check for Advisor candidates, so lazy-init does not skip the type check — the FHIR module's tests load billing-api's moduleApplicationContext.xml and crashed with NoClassDefFoundError on AbstractRecordSerializer because provided-scope deps don't propagate to dependent modules' test classpaths. Adds querystore-api as a test-scope dependency in fhir/pom.xml so the test classpath is self-sufficient. The omod packaging is unaffected — querystore-api is still provided at the API layer, so it isn't bundled. Also adds lazy-init on the four querystore beans in moduleApplicationContext.xml. This doesn't fix the Advisor-scan path above, but it does prevent unrelated bean lookups from forcing the classes to load at startup when querystore is genuinely absent at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/moduleApplicationContext.xml | 26 +++++++++++++------ fhir/pom.xml | 11 ++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index dc0a2325..a6325367 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -337,23 +337,33 @@ - + + class="org.openmrs.module.billing.api.querystore.BillRecordSerializer" + lazy-init="true"/> + class="org.openmrs.module.billing.api.querystore.BillRefundRecordSerializer" + lazy-init="true"/> + class="org.openmrs.module.billing.api.querystore.BillResourceTypeProvider" + lazy-init="true"> + class="org.openmrs.module.billing.api.querystore.BillRefundResourceTypeProvider" + lazy-init="true"> diff --git a/fhir/pom.xml b/fhir/pom.xml index 6d033a34..802ed376 100644 --- a/fhir/pom.xml +++ b/fhir/pom.xml @@ -44,6 +44,17 @@ billing-api ${project.parent.version} + + + org.openmrs.module + querystore-api + test + org.openmrs.module fhir2-api From aef5b092d6fcd63783ae015b571d7711b221fd63 Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 02:18:00 +0300 Subject: [PATCH 3/9] Harden the querystore SPI slice: tests + missing trigger + polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — test coverage: - Adds 7 unit tests for BillRecordSerializer including the balance-from-amountAfterDiscount correctness invariant, and 8 for BillRefundRecordSerializer (core fields, metadata, optional refs, null guards, line-scoped vs bill-scoped refunds). Phase 1 — re-entry, real correctness fix: - BillLineItemServiceImpl.voidBillLineItem now re-saves the parent Bill so dateChanged advances and the BillIndexingAdvice picks up the resulting save. Without this, voiding a line item changed the bill's getTotal() / getAmountAfterDiscount() / getTotalPayments() without touching any bill column, leaving the indexed Bill document stale. Symmetric to the existing discount-touch fix. Phase 2 — polish: - BillRefundIndexingAdvice TRIGGER_METHODS shrunk to {saveBillRefund} and PURGE_METHODS to empty. The previous set named void/unvoid/purge methods that don't exist on BillRefundService — aspirational coverage that AOP could never match. - BillRefundRecordSerializer skips refunds with null refundAmount instead of NPE-ing inside the AOP advice's per-entity exception swallow (which would silently drop the refund from the index). - touchParentBill log.error -> log.warn for the recoverable "bill could not be loaded" race in both BillDiscountServiceImpl and the new BillLineItemServiceImpl. Deferred to follow-up: extracting the privilege+load+save pattern now duplicated in 4 places (touches code outside this slice); investigating Bill.hbm.xml cascade-delete on refunds (pre-existing entity-mapping concern); the constructor-injection-vs-bean-name lookup divergence between providers and advice (both shapes work, the SPI's documented pattern uses bean-name lookup). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/impl/BillDiscountServiceImpl.java | 5 +- .../api/impl/BillLineItemServiceImpl.java | 32 +++ .../querystore/BillRefundIndexingAdvice.java | 12 +- .../BillRefundRecordSerializer.java | 6 + .../billing/api/BillLineItemServiceTest.java | 25 +++ .../querystore/BillRecordSerializerTest.java | 195 ++++++++++++++++++ .../BillRefundRecordSerializerTest.java | 167 +++++++++++++++ 7 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java create mode 100644 api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java index a564b3a4..e468ee6e 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java +++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java @@ -87,7 +87,10 @@ private void touchParentBill(BillDiscount discount) { Context.addProxyPrivilege(PrivilegeConstants.MANAGE_BILLS); Bill freshBill = Context.getService(BillService.class).getBill(billId); if (freshBill == null) { - log.error("Discount {} references bill {} which could not be loaded; parent bill not touched", + // Bill was concurrently voided/purged between this discount's save and the reload — + // recoverable race, not a hard failure. warn rather than error so ops dashboards + // don't page on routine concurrent edits. + log.warn("Discount {} references bill {} which could not be loaded; parent bill not touched", discount.getUuid(), billId); return; } 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 05c4655d..f21c086e 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 @@ -10,16 +10,21 @@ package org.openmrs.module.billing.api.impl; import java.util.Collections; +import java.util.Date; import java.util.List; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.openmrs.Order; +import org.openmrs.api.context.Context; import org.openmrs.api.impl.BaseOpenmrsService; import org.openmrs.module.billing.api.BillLineItemService; +import org.openmrs.module.billing.api.BillService; import org.openmrs.module.billing.api.db.BillLineItemDAO; +import org.openmrs.module.billing.api.model.Bill; import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.util.PrivilegeConstants; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -62,5 +67,32 @@ public void voidBillLineItem(BillLineItem lineItem, String voidReason) { throw new IllegalArgumentException("voidReason cannot be null or empty"); } billLineItemDAO.saveBillLineItem(lineItem); + touchParentBill(lineItem); + } + + // Bill.getTotal() / getAmountAfterDiscount() / getTotalPayments() all skip voided line items, + // so voiding a line item changes the bill's effective value without touching any bill column. + // Re-save the parent so dateChanged advances and the querystore BillIndexingAdvice fires on + // the resulting BillService.saveBill. + private void touchParentBill(BillLineItem lineItem) { + Integer billId = lineItem.getBill() == null ? null : lineItem.getBill().getId(); + if (billId == null) { + log.error("Voided line item {} has no associated bill; skipping parent bill touch", lineItem.getUuid()); + return; + } + try { + Context.addProxyPrivilege(PrivilegeConstants.MANAGE_BILLS); + Bill freshBill = Context.getService(BillService.class).getBill(billId); + if (freshBill == null) { + log.warn("Line item {} references bill {} which could not be loaded; parent bill not touched", + lineItem.getUuid(), billId); + return; + } + freshBill.setDateChanged(new Date()); + Context.getService(BillService.class).saveBill(freshBill); + } + finally { + Context.removeProxyPrivilege(PrivilegeConstants.MANAGE_BILLS); + } } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java index ed7a8993..c90a47f3 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java @@ -9,9 +9,7 @@ */ package org.openmrs.module.billing.api.querystore; -import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.Set; import org.openmrs.api.context.Context; @@ -20,10 +18,14 @@ public class BillRefundIndexingAdvice extends AbstractIndexingAdvice { - static final Set TRIGGER_METHODS = new HashSet<>( - Arrays.asList("saveBillRefund", "voidBillRefund", "unvoidBillRefund", "purgeBillRefund")); + // BillRefundService exposes only saveBillRefund — void/unvoid happen by setting the voided + // flag on the entity and calling saveBillRefund (AbstractIndexingAdvice's per-node voided + // policy routes voided records to delete on the resave path). There is no purgeBillRefund + // method, so PURGE_METHODS is empty rather than aspirationally listing a name AOP can never + // match — if hard-delete becomes a real path it should land on a service method first. + static final Set TRIGGER_METHODS = Collections.singleton("saveBillRefund"); - static final Set PURGE_METHODS = Collections.singleton("purgeBillRefund"); + static final Set PURGE_METHODS = Collections.emptySet(); @Override protected Class getSupportedType() { diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java index 2b4db8fb..96d2a09c 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java @@ -58,6 +58,12 @@ protected void populate(BillRefund refund, QueryDocument doc) { if (bill == null || bill.getPatient() == null) { return; } + // Defensive: AbstractIndexingAdvice swallows RuntimeException per-entity, so an NPE here + // would silently drop the refund from the index with only a warn-level log. A partially + // constructed refund (validator gap, recovered transient) should be skipped, not crash. + if (refund.getRefundAmount() == null) { + return; + } RefundStatus status = refund.getStatus(); String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid(); diff --git a/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java b/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java index a6e3fd90..67c85026 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java @@ -17,6 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; @@ -84,6 +86,29 @@ public void voidBillLineItem_shouldVoidLineItem() { assertNotNull(reloaded.getVoidedBy()); } + @Test + public void voidBillLineItem_shouldAdvanceParentBillDateChanged() { + // Bill.getTotal() / getAmountAfterDiscount() skip voided line items, so voiding one + // changes the bill's effective value without touching any bill column. The parent bill + // must be re-saved so dateChanged advances and consumers see the change. + // Truncate to second precision: the DB TIMESTAMP column drops sub-second precision. + Date beforeVoid = Date.from(Instant.now().truncatedTo(ChronoUnit.SECONDS)); + + Bill bill = createBillWithLineItem(); + BillLineItem lineItem = bill.getLineItems().get(0); + lineItemService.voidBillLineItem(lineItem, "Test parent-bill touch"); + Context.flushSession(); + Context.clearSession(); + + Bill parentBill = billService.getBill(bill.getId()); + assertNotNull(parentBill); + Date afterDateChanged = parentBill.getDateChanged(); + assertNotNull(afterDateChanged, "Parent Bill.dateChanged must be set after a line-item void"); + assertTrue(afterDateChanged.compareTo(beforeVoid) >= 0, + "Parent Bill.dateChanged must be at-or-after the void timestamp (was " + afterDateChanged + ", expected >= " + + beforeVoid + ")"); + } + @Test public void voidBillLineItem_shouldThrowWhenReasonIsBlank() { Bill bill = createBillWithLineItem(); diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java new file mode 100644 index 00000000..cfcf1b06 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java @@ -0,0 +1,195 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; + +import org.junit.jupiter.api.Test; +import org.openmrs.Patient; +import org.openmrs.Provider; +import org.openmrs.Visit; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillDiscount; +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.DiscountStatus; +import org.openmrs.module.billing.api.model.DiscountType; +import org.openmrs.module.querystore.model.QueryDocument; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class BillRecordSerializerTest { + + private static final String BILL_UUID = "bill-uuid-1"; + + private static final String PATIENT_UUID = "patient-uuid-1"; + + private final BillRecordSerializer serializer = new BillRecordSerializer(); + + @Test + public void serialize_shouldSetCoreFieldsFromBill() { + Bill bill = postedBillWithLineItem("R-001", new BigDecimal("100"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_BILL, doc.getResourceType()); + assertEquals(BILL_UUID, doc.getResourceUuid()); + assertEquals(PATIENT_UUID, doc.getPatientUuid()); + assertNotNull(doc.getDate(), "date must be set so the document is queryable by clinical date"); + } + + @Test + public void serialize_shouldDeriveBalanceFromAmountAfterDiscount() { + // total=100, approved discount=30, paid=50 → amount_after_discount=70, balance=20. + // If balance were derived from gross total instead, it would report 50 — overstating + // what's still owed and breaking dashboards built on the balance field. + Bill bill = postedBillWithLineItem("R-002", new BigDecimal("100"), 1); + addApprovedDiscount(bill, new BigDecimal("30")); + addPayment(bill, new BigDecimal("50")); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(new BigDecimal("100"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_TOTAL)); + assertEquals(new BigDecimal("70"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_AMOUNT_AFTER_DISCOUNT)); + assertEquals(new BigDecimal("50"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_TOTAL_PAID)); + assertEquals(new BigDecimal("20"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BALANCE)); + } + + @Test + public void serialize_shouldClampBalanceWhenAmountAfterDiscountIsZero() { + // Approved discount exceeds total. getAmountAfterDiscount() clamps to zero, so balance = + // 0 - payments. A negative balance means the bill is over-credited; consumers can detect + // drift by checking balance < 0. + Bill bill = postedBillWithLineItem("R-003", new BigDecimal("100"), 1); + addApprovedDiscount(bill, new BigDecimal("150")); + addPayment(bill, new BigDecimal("0")); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(BigDecimal.ZERO, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_AMOUNT_AFTER_DISCOUNT)); + } + + @Test + public void serialize_shouldIncludeCashierCashPointAndVisitWhenPresent() { + Bill bill = postedBillWithLineItem("R-004", new BigDecimal("50"), 1); + Provider cashier = new Provider(); + cashier.setUuid("cashier-uuid"); + bill.setCashier(cashier); + CashPoint cashPoint = new CashPoint(); + cashPoint.setUuid("cashpoint-uuid"); + cashPoint.setName("Main Counter"); + bill.setCashPoint(cashPoint); + Visit visit = new Visit(); + visit.setUuid("visit-uuid"); + bill.setVisit(visit); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals("cashier-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_UUID)); + assertEquals("cashpoint-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID)); + assertEquals("Main Counter", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME)); + assertEquals("visit-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VISIT_UUID)); + } + + @Test + public void serialize_shouldOmitOptionalReferencesWhenAbsent() { + Bill bill = postedBillWithLineItem("R-005", new BigDecimal("50"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASHIER_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_VISIT_UUID)); + } + + @Test + public void serialize_shouldFallBackToUuidInTextWhenReceiptNumberAbsent() { + Bill bill = postedBillWithLineItem(null, new BigDecimal("50"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + // receipt-number metadata is null; the searchable text falls back to the bill UUID so + // the document still has a stable label for indexing. + assertNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER)); + assertTrue(doc.getText().contains(BILL_UUID), + "text must include the bill UUID when receipt number is absent: " + doc.getText()); + } + + @Test + public void serialize_shouldReturnNullWhenPatientAbsent() { + // Without a patient, the serializer cannot produce a document keyed to a patient. Returning + // null short-circuits indexing for this record (per the AbstractRecordSerializer contract: + // empty text → null document). + Bill bill = postedBillWithLineItem("R-006", new BigDecimal("50"), 1); + bill.setPatient(null); + + QueryDocument doc = serializer.serialize(bill); + + assertNull(doc); + } + + private Bill postedBillWithLineItem(String receiptNumber, BigDecimal price, int quantity) { + Bill bill = new Bill(); + bill.setUuid(BILL_UUID); + bill.setReceiptNumber(receiptNumber); + Patient patient = new Patient(); + patient.setUuid(PATIENT_UUID); + bill.setPatient(patient); + bill.setStatus(BillStatus.POSTED); + bill.setDateCreated(new Date()); + bill.setLineItems(new ArrayList<>()); + BillLineItem lineItem = new BillLineItem(); + lineItem.setPrice(price); + lineItem.setQuantity(quantity); + lineItem.setVoided(false); + bill.getLineItems().add(lineItem); + return bill; + } + + private void addApprovedDiscount(Bill bill, BigDecimal amount) { + if (bill.getDiscounts() == null) { + bill.setDiscounts(new HashSet<>()); + } + // getDiscountAmount() derives from discountValue + discountType, not a stored column — + // FIXED_AMOUNT means amount == value. + BillDiscount discount = new BillDiscount(); + discount.setDiscountType(DiscountType.FIXED_AMOUNT); + discount.setDiscountValue(amount); + discount.setStatus(DiscountStatus.APPROVED); + discount.setVoided(false); + bill.getDiscounts().add(discount); + } + + private void addPayment(Bill bill, BigDecimal amount) { + if (bill.getPayments() == null) { + bill.setPayments(new HashSet<>()); + } + org.openmrs.module.billing.api.model.Payment payment = new org.openmrs.module.billing.api.model.Payment(); + payment.setAmount(amount); + payment.setAmountTendered(amount); + payment.setVoided(false); + bill.getPayments().add(payment); + } +} diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java new file mode 100644 index 00000000..6569ff0e --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java @@ -0,0 +1,167 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.math.BigDecimal; +import java.util.Date; + +import org.junit.jupiter.api.Test; +import org.openmrs.Patient; +import org.openmrs.User; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillRefund; +import org.openmrs.module.billing.api.model.RefundStatus; +import org.openmrs.module.querystore.model.QueryDocument; + +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.assertNull; + +public class BillRefundRecordSerializerTest { + + private static final String REFUND_UUID = "refund-uuid-1"; + + private static final String BILL_UUID = "bill-uuid-1"; + + private static final String PATIENT_UUID = "patient-uuid-1"; + + private final BillRefundRecordSerializer serializer = new BillRefundRecordSerializer(); + + @Test + public void serialize_shouldSetCoreFieldsFromRefund() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "Patient error"); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_BILL_REFUND, doc.getResourceType()); + assertEquals(REFUND_UUID, doc.getResourceUuid()); + assertEquals(PATIENT_UUID, doc.getPatientUuid()); + assertNotNull(doc.getDate()); + } + + @Test + public void serialize_shouldSetMetadataFields() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.APPROVED, "Patient error"); + refund.setDateApproved(new Date()); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals(BILL_UUID, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_UUID)); + assertEquals(new BigDecimal("50.00"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_REFUND_AMOUNT)); + assertEquals("APPROVED", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_STATUS)); + assertEquals("Patient error", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_REASON)); + assertNotNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_APPROVED)); + } + + @Test + public void serialize_shouldIncludeLineItemUuidWhenLineScoped() { + BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.REQUESTED, "Line correction"); + BillLineItem lineItem = new BillLineItem(); + lineItem.setUuid("line-item-uuid-1"); + refund.setLineItem(lineItem); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals("line-item-uuid-1", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID)); + } + + @Test + public void serialize_shouldIncludeInitiatorApproverCompleterWhenPresent() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.COMPLETED, "Patient error"); + User initiator = userWithUuid("initiator-uuid"); + User approver = userWithUuid("approver-uuid"); + User completer = userWithUuid("completer-uuid"); + refund.setInitiator(initiator); + refund.setApprover(approver); + refund.setCompleter(completer); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals("initiator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_INITIATOR_UUID)); + assertEquals("approver-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_APPROVER_UUID)); + assertEquals("completer-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_COMPLETER_UUID)); + } + + @Test + public void serialize_shouldOmitOptionalReferencesWhenAbsent() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "Patient error"); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_INITIATOR_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_APPROVER_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_COMPLETER_UUID)); + } + + @Test + public void serialize_shouldReturnNullWhenRefundAmountAbsent() { + // Defensive null-guard: a partially constructed refund (validator gap, recovered transient) + // must be skipped, not crash. Without the guard, the advice's per-entity RuntimeException + // swallow would drop this refund from the index with only a warn log. + BillRefund refund = newRefund(null, RefundStatus.REQUESTED, "any"); + + QueryDocument doc = serializer.serialize(refund); + + assertNull(doc); + } + + @Test + public void serialize_shouldReturnNullWhenBillAbsent() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any"); + refund.setBill(null); + + QueryDocument doc = serializer.serialize(refund); + + assertNull(doc); + } + + @Test + public void serialize_shouldReturnNullWhenPatientAbsent() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any"); + refund.getBill().setPatient(null); + + QueryDocument doc = serializer.serialize(refund); + + assertNull(doc); + } + + private BillRefund newRefund(BigDecimal amount, RefundStatus status, String reason) { + Bill bill = new Bill(); + bill.setUuid(BILL_UUID); + bill.setReceiptNumber("R-100"); + Patient patient = new Patient(); + patient.setUuid(PATIENT_UUID); + bill.setPatient(patient); + + BillRefund refund = new BillRefund(); + refund.setUuid(REFUND_UUID); + refund.setBill(bill); + refund.setRefundAmount(amount); + refund.setStatus(status); + refund.setReason(reason); + refund.setVoided(false); + refund.setDateCreated(new Date()); + return refund; + } + + private User userWithUuid(String uuid) { + User user = new User(); + user.setUuid(uuid); + return user; + } +} From cf6a37cd13b56fbc75b1e363213230a18676130a Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 02:37:24 +0300 Subject: [PATCH 4/9] Verify indexing advice trigger/purge names exist on their target services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typo in BillIndexingAdvice.TRIGGER_METHODS or BillRefundIndexingAdvice's matching set produces no compile error, no startup error, and no runtime exception — AbstractIndexingAdvice matches by method name, and a name that doesn't resolve simply never fires. The failure surfaces only as "stale rows in the read store" some time later. This was exactly the bug shape we corrected on BillRefundIndexingAdvice this cycle when it listed voidBillRefund / unvoidBillRefund / purgeBillRefund — methods that don't exist on BillRefundService. The reflection test now asserts every name in the trigger and purge sets resolves to a method on the target service interface and that purge methods are a subset of trigger methods (per the AbstractIndexingAdvice contract). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../querystore/IndexingAdviceConfigTest.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java new file mode 100644 index 00000000..a48ce82f --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java @@ -0,0 +1,72 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.lang.reflect.Method; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.openmrs.module.billing.api.BillRefundService; +import org.openmrs.module.billing.api.BillService; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +// AbstractIndexingAdvice matches advised invocations by method NAME against the configured +// trigger/purge sets. A typo in one of those names produces no compile error, no startup error, +// and no runtime exception — the advice simply never fires on that name, the document is never +// indexed or deleted, and the failure surfaces only as "stale rows in the read store" weeks +// later. These tests catch the typo class at unit-test time by reflecting against the target +// service interface declared in omod/config.xml's . +public class IndexingAdviceConfigTest { + + @Test + public void billIndexingAdvice_triggerMethodsShouldAllExistOnBillService() { + assertAllMethodsExist(BillIndexingAdvice.TRIGGER_METHODS, BillService.class, "BillIndexingAdvice"); + } + + @Test + public void billIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() { + assertSubset(BillIndexingAdvice.PURGE_METHODS, BillIndexingAdvice.TRIGGER_METHODS, "BillIndexingAdvice"); + } + + @Test + public void billRefundIndexingAdvice_triggerMethodsShouldAllExistOnBillRefundService() { + assertAllMethodsExist(BillRefundIndexingAdvice.TRIGGER_METHODS, BillRefundService.class, "BillRefundIndexingAdvice"); + } + + @Test + public void billRefundIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() { + assertSubset(BillRefundIndexingAdvice.PURGE_METHODS, BillRefundIndexingAdvice.TRIGGER_METHODS, + "BillRefundIndexingAdvice"); + } + + private static void assertAllMethodsExist(Set methodNames, Class iface, String adviceName) { + for (String name : methodNames) { + assertTrue(hasMethod(iface, name), adviceName + " references method '" + name + "' but no such method exists on " + + iface.getSimpleName() + " — AOP would silently never fire on this name"); + } + } + + private static void assertSubset(Set subset, Set superset, String adviceName) { + assertTrue(superset.containsAll(subset), + adviceName + ": PURGE_METHODS must be a subset of TRIGGER_METHODS per the AbstractIndexingAdvice " + + "contract — a name in purge but not in trigger never fires the advice, so the purge path " + + "is unreachable"); + } + + private static boolean hasMethod(Class iface, String name) { + for (Method m : iface.getMethods()) { + if (m.getName().equals(name)) { + return true; + } + } + return false; + } +} From d49f7825f5f91fb09bf7bdfafb026bf329d9b02c Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 13:43:49 +0300 Subject: [PATCH 5/9] Index bill line item names into the querystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bill and BillRefund documents previously carried only IDs and totals — they had no signal a search like "which bills include item X" could match against. The serializers now collect each non-voided line item's display name (BillableService.name first, falling back to StockItem.commonName) and emit them into both the searchable text blob and a new `line_item_names` metadata field shaped as `List` to match the querystore module's convention for multi-valued fields (VisitRecordSerializer's encounter_uuids, AllergyRecordSerializer's reactions). The refund serializer emits a singleton list under the same key — refunds are line-scoped — and preserves the line item's name even when that line item has since been voided on the parent bill, because a refund is an audit record of past activity. The display-name lookup is extracted into a package-private helper scoped to the querystore SPI. It is intentionally NOT the source for ReceiptGenerator (which prefers Drug.name on printed receipts) or the FHIR translator (which keys off Concept presence); "consolidating" the three consumers would silently change user-visible receipts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/querystore/BillRecordSerializer.java | 34 +++- .../BillRefundRecordSerializer.java | 19 ++- .../api/querystore/BillingDisplayNames.java | 40 +++++ .../BillingQueryStoreConstants.java | 6 + .../querystore/BillRecordSerializerTest.java | 153 +++++++++++++++++- .../BillRefundRecordSerializerTest.java | 89 ++++++++++ 6 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java index 83362215..6f20fbcc 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java @@ -11,10 +11,13 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import org.openmrs.Patient; import org.openmrs.Visit; 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.querystore.model.QueryDocument; import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; @@ -63,11 +66,21 @@ protected void populate(Bill bill, QueryDocument doc) { // gross total here would over-state what's still owed. BigDecimal balance = amountAfterDiscount.subtract(totalPaid); + // Multi-valued metadata is stored as a List per the querystore module convention + // (see VisitRecordSerializer's FIELD_ENCOUNTER_UUIDS, AllergyRecordSerializer's FIELD_REACTIONS). + // Storing a comma-joined string would force consumers into substring matching, breaking + // exact-match queries like "bills containing item X". + List itemNames = collectLineItemNames(bill); + String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid(); - doc.setText(String.format("Bill %s. Status: %s. Total: %s. Paid: %s. Balance: %s.", receiptOrUuid, + String itemsClause = itemNames.isEmpty() ? "" : " Items: " + String.join(", ", itemNames) + "."; + doc.setText(String.format("Bill %s. Status: %s. Total: %s. Paid: %s. Balance: %s.%s", receiptOrUuid, status != null ? status.name() : "UNKNOWN", total.toPlainString(), totalPaid.toPlainString(), - balance.toPlainString())); + balance.toPlainString(), itemsClause)); + if (!itemNames.isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES, itemNames); + } doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber()); doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null); doc.putMetadata(BillingQueryStoreConstants.FIELD_TOTAL, total); @@ -88,4 +101,21 @@ protected void populate(Bill bill, QueryDocument doc) { doc.putMetadata(BillingQueryStoreConstants.FIELD_VISIT_UUID, visit.getUuid()); } } + + private List collectLineItemNames(Bill bill) { + List names = new ArrayList<>(); + if (bill.getLineItems() == null) { + return names; + } + for (BillLineItem lineItem : bill.getLineItems()) { + if (lineItem == null || lineItem.getVoided()) { + continue; + } + String name = BillingDisplayNames.lineItemDisplayName(lineItem); + if (name != null) { + names.add(name); + } + } + return names; + } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java index 96d2a09c..f5f7ac9f 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java @@ -10,6 +10,7 @@ package org.openmrs.module.billing.api.querystore; import java.time.LocalDate; +import java.util.Collections; import org.openmrs.Patient; import org.openmrs.module.billing.api.model.Bill; @@ -67,9 +68,17 @@ protected void populate(BillRefund refund, QueryDocument doc) { RefundStatus status = refund.getStatus(); String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid(); - doc.setText(String.format("Refund of %s for bill %s. Status: %s. Reason: %s.", + // A refund is an audit record of a past line item. Even if the line item has since been + // voided on the bill, the refund must still carry the item's name so the audit trail + // reads coherently — the parent bill's indexed names omit voided items, but the refund's + // own indexed name preserves them. + String itemName = BillingDisplayNames.lineItemDisplayName(refund.getLineItem()); + // Singular "Item:" (vs. the bill's plural "Items:") is intentional — a refund row is + // always line-scoped, so consumers parsing the text blob can rely on at most one item. + String itemClause = itemName != null ? " Item: " + itemName + "." : ""; + doc.setText(String.format("Refund of %s for bill %s. Status: %s. Reason: %s.%s", refund.getRefundAmount().toPlainString(), receiptOrUuid, status != null ? status.name() : "UNKNOWN", - refund.getReason() != null ? refund.getReason() : "")); + refund.getReason() != null ? refund.getReason() : "", itemClause)); doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_UUID, bill.getUuid()); doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber()); @@ -84,6 +93,12 @@ protected void populate(BillRefund refund, QueryDocument doc) { if (lineItem != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID, lineItem.getUuid()); } + if (itemName != null) { + // Stored as a singleton list to match the Bill serializer's shape — consumers branch + // on resource type but share the field key, so a String here against a List there + // would surface as ClassCastException downstream. + doc.putMetadata(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES, Collections.singletonList(itemName)); + } if (refund.getInitiator() != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_INITIATOR_UUID, refund.getInitiator().getUuid()); } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java new file mode 100644 index 00000000..a823bf9e --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillableService; +import org.openmrs.module.stockmanagement.api.model.StockItem; + +// Display-name lookup scoped to the querystore SPI — shared only by BillRecordSerializer and +// BillRefundRecordSerializer so their indexed item names stay consistent. Intentionally NOT the +// source for ReceiptGenerator (which prefers Drug.name on printed receipts) or the FHIR +// translator (which keys off Concept presence). Pulling this helper out of the querystore +// package and "consolidating" the three consumers would silently change user-visible receipts. +final class BillingDisplayNames { + + private BillingDisplayNames() { + } + + static String lineItemDisplayName(BillLineItem lineItem) { + if (lineItem == null) { + return null; + } + BillableService service = lineItem.getBillableService(); + if (service != null && service.getName() != null && !service.getName().isEmpty()) { + return service.getName(); + } + StockItem item = lineItem.getItem(); + if (item != null && item.getCommonName() != null && !item.getCommonName().isEmpty()) { + return item.getCommonName(); + } + return null; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java index b5584ee0..9819625e 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java @@ -54,6 +54,12 @@ final class BillingQueryStoreConstants { static final String FIELD_BILL_LINE_ITEM_UUID = "bill_line_item_uuid"; + // Shape contract: List on BOTH bill and refund docs. The bill emits one entry per + // non-voided line item; the refund emits a singleton (refunds are line-scoped). The shared + // key + uniform list shape lets a single "find every record referencing item X" query span + // both resource types without type-branching downstream. + static final String FIELD_LINE_ITEM_NAMES = "line_item_names"; + static final String FIELD_INITIATOR_UUID = "initiator_uuid"; static final String FIELD_APPROVER_UUID = "approver_uuid"; diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java index cfcf1b06..749987f4 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java @@ -11,8 +11,10 @@ import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.HashSet; +import java.util.List; import org.junit.jupiter.api.Test; import org.openmrs.Patient; @@ -22,10 +24,12 @@ import org.openmrs.module.billing.api.model.BillDiscount; 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.CashPoint; import org.openmrs.module.billing.api.model.DiscountStatus; import org.openmrs.module.billing.api.model.DiscountType; import org.openmrs.module.querystore.model.QueryDocument; +import org.openmrs.module.stockmanagement.api.model.StockItem; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -137,6 +141,135 @@ public void serialize_shouldFallBackToUuidInTextWhenReceiptNumberAbsent() { "text must include the bill UUID when receipt number is absent: " + doc.getText()); } + @Test + public void serialize_shouldIncludeBillableServiceNameInTextAndLineItemNamesMetadata() { + // "Find every bill that includes service X" is the core query this slice enables; if the + // name does not make it into either the text blob or the structured list, that query has + // no signal to match against. + Bill bill = postedBillWithLineItem("R-100", new BigDecimal("50"), 1); + setBillableService(bill.getLineItems().get(0), "Consultation"); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(java.util.Collections.singletonList("Consultation"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + assertTrue(doc.getText().contains("Items: Consultation."), + "text must surface the line item name for full-text search: " + doc.getText()); + } + + @Test + public void serialize_shouldFallBackToStockItemCommonNameWhenBillableServiceAbsent() { + Bill bill = postedBillWithLineItem("R-101", new BigDecimal("50"), 1); + setStockItem(bill.getLineItems().get(0), "Paracetamol 500mg"); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(java.util.Collections.singletonList("Paracetamol 500mg"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + + @Test + public void serialize_shouldPreferBillableServiceNameOverStockItemCommonName() { + // Both populated → service wins. A line item with both fields set is rare but possible; + // pinning the precedence here prevents future refactors from silently flipping it. + Bill bill = postedBillWithLineItem("R-102", new BigDecimal("50"), 1); + setBillableService(bill.getLineItems().get(0), "Consultation"); + setStockItem(bill.getLineItems().get(0), "Paracetamol 500mg"); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(java.util.Collections.singletonList("Consultation"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + + @Test + public void serialize_shouldSkipVoidedLineItemsFromLineItemNames() { + // Voided line items are excluded from total/balance computations, so they must also be + // excluded from the indexed names — otherwise a voided item leaves a phantom hit in the + // index that contradicts the bill's effective state. + Bill bill = postedBillWithLineItem("R-103", new BigDecimal("50"), 1); + setBillableService(bill.getLineItems().get(0), "ActiveService"); + BillLineItem voided = newLineItem(new BigDecimal("10"), 1); + voided.setVoided(true); + setBillableService(voided, "VoidedService"); + bill.getLineItems().add(voided); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(java.util.Collections.singletonList("ActiveService"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + assertFalse(doc.getText().contains("VoidedService"), + "voided line item names must not appear in the indexed text: " + doc.getText()); + } + + @Test + public void serialize_shouldOmitLineItemNamesMetadataWhenAllLineItemsVoided() { + Bill bill = postedBillWithLineItem("R-104", new BigDecimal("50"), 1); + bill.getLineItems().get(0).setVoided(true); + setBillableService(bill.getLineItems().get(0), "VoidedOnly"); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES), + "metadata key must be absent (not empty list) when no names are eligible"); + assertFalse(doc.getText().contains("Items:")); + } + + @Test + public void serialize_shouldJoinMultipleLineItemNamesInOrder() { + Bill bill = postedBillWithLineItem("R-105", new BigDecimal("10"), 1); + setBillableService(bill.getLineItems().get(0), "First"); + BillLineItem second = newLineItem(new BigDecimal("20"), 1); + setBillableService(second, "Second"); + bill.getLineItems().add(second); + BillLineItem third = newLineItem(new BigDecimal("30"), 1); + setStockItem(third, "Third"); + bill.getLineItems().add(third); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + List expected = Arrays.asList("First", "Second", "Third"); + assertEquals(expected, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + assertTrue(doc.getText().contains("Items: First, Second, Third."), + "text must preserve insertion order and join with ', ': " + doc.getText()); + } + + @Test + public void serialize_shouldSkipLineItemWithNeitherServiceNorStockItem() { + // Rounding rows (e.g., RoundingUtil.findRoundingLineItem) have neither field set — they + // should not pollute the search text with a blank/null entry. + Bill bill = postedBillWithLineItem("R-106", new BigDecimal("50"), 1); + setBillableService(bill.getLineItems().get(0), "RealItem"); + BillLineItem bareRounding = newLineItem(new BigDecimal("0.01"), 1); + bill.getLineItems().add(bareRounding); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(java.util.Collections.singletonList("RealItem"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + + @Test + public void serialize_shouldHandleNullLineItemsCollection() { + // Bills loaded through certain paths can have an unset lineItems collection; collectLineItemNames + // must not NPE in that case. The indexing advice swallows RuntimeException per-entity, so an NPE + // here would silently drop the bill from the index with only a warn-level log. + Bill bill = postedBillWithLineItem("R-107", new BigDecimal("50"), 1); + bill.setLineItems(null); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + @Test public void serialize_shouldReturnNullWhenPatientAbsent() { // Without a patient, the serializer cannot produce a document keyed to a patient. Returning @@ -160,12 +293,28 @@ private Bill postedBillWithLineItem(String receiptNumber, BigDecimal price, int bill.setStatus(BillStatus.POSTED); bill.setDateCreated(new Date()); bill.setLineItems(new ArrayList<>()); + bill.getLineItems().add(newLineItem(price, quantity)); + return bill; + } + + private static BillLineItem newLineItem(BigDecimal price, int quantity) { BillLineItem lineItem = new BillLineItem(); lineItem.setPrice(price); lineItem.setQuantity(quantity); lineItem.setVoided(false); - bill.getLineItems().add(lineItem); - return bill; + return lineItem; + } + + private static void setBillableService(BillLineItem lineItem, String name) { + BillableService service = new BillableService(); + service.setName(name); + lineItem.setBillableService(service); + } + + private static void setStockItem(BillLineItem lineItem, String commonName) { + StockItem item = new StockItem(); + item.setCommonName(commonName); + lineItem.setItem(item); } private void addApprovedDiscount(Bill bill, BigDecimal amount) { diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java index 6569ff0e..bda94a98 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java @@ -10,6 +10,7 @@ package org.openmrs.module.billing.api.querystore; import java.math.BigDecimal; +import java.util.Collections; import java.util.Date; import org.junit.jupiter.api.Test; @@ -18,13 +19,16 @@ import org.openmrs.module.billing.api.model.Bill; import org.openmrs.module.billing.api.model.BillLineItem; import org.openmrs.module.billing.api.model.BillRefund; +import org.openmrs.module.billing.api.model.BillableService; import org.openmrs.module.billing.api.model.RefundStatus; import org.openmrs.module.querystore.model.QueryDocument; +import org.openmrs.module.stockmanagement.api.model.StockItem; 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class BillRefundRecordSerializerTest { @@ -95,6 +99,82 @@ public void serialize_shouldIncludeInitiatorApproverCompleterWhenPresent() { assertEquals("completer-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_COMPLETER_UUID)); } + @Test + public void serialize_shouldIncludeLineItemBillableServiceNameInTextAndMetadata() { + // Mirrors the bill serializer: a singleton list under the shared FIELD_LINE_ITEM_NAMES key + // so downstream consumers can read the field uniformly across resource types. + BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.REQUESTED, "Line correction"); + BillLineItem lineItem = newLineItemWithBillableService("Consultation"); + refund.setLineItem(lineItem); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals(Collections.singletonList("Consultation"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + assertTrue(doc.getText().contains("Item: Consultation."), + "refund text must surface the refunded line item name: " + doc.getText()); + } + + @Test + public void serialize_shouldFallBackToStockItemCommonNameForRefundLineItem() { + BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.REQUESTED, "any"); + BillLineItem lineItem = new BillLineItem(); + lineItem.setVoided(false); + StockItem item = new StockItem(); + item.setCommonName("Paracetamol 500mg"); + lineItem.setItem(item); + refund.setLineItem(lineItem); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals(Collections.singletonList("Paracetamol 500mg"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + + @Test + public void serialize_shouldOmitLineItemNamesMetadataWhenLineItemAbsent() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any"); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + assertFalse(doc.getText().contains("Item:")); + } + + @Test + public void serialize_shouldOmitLineItemNamesMetadataWhenLineItemHasNeitherServiceNorStockItem() { + BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any"); + BillLineItem lineItem = new BillLineItem(); + lineItem.setUuid("line-item-uuid-x"); + refund.setLineItem(lineItem); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + + @Test + public void serialize_shouldStillIncludeRefundLineItemNameWhenLineItemIsVoided() { + // Refunds are an audit record of past activity. The parent bill's indexed names omit voided + // line items, but the refund's own indexed name preserves them so the audit trail reads + // coherently — querying for refunds of "ServiceA" must still hit the refund even if the + // underlying line item has since been voided. + BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.COMPLETED, "any"); + BillLineItem lineItem = newLineItemWithBillableService("OnceBilledService"); + lineItem.setVoided(true); + refund.setLineItem(lineItem); + + QueryDocument doc = serializer.serialize(refund); + + assertNotNull(doc); + assertEquals(Collections.singletonList("OnceBilledService"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); + } + @Test public void serialize_shouldOmitOptionalReferencesWhenAbsent() { BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "Patient error"); @@ -164,4 +244,13 @@ private User userWithUuid(String uuid) { user.setUuid(uuid); return user; } + + private static BillLineItem newLineItemWithBillableService(String name) { + BillLineItem lineItem = new BillLineItem(); + lineItem.setVoided(false); + BillableService service = new BillableService(); + service.setName(name); + lineItem.setBillableService(service); + return lineItem; + } } From bc4e88fb67bfa912c364d6d112dd4e2a2b71893a Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 15:45:54 +0300 Subject: [PATCH 6/9] Index payments, discounts, and bill adjustments into the querystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bill document now carries the data needed to answer ops/finance queries that the prior index could not: payment_modes (distinct sorted tender names for "settlements by Mobile Money"), discount_statuses (presence query for "bills with a pending discount"), bill_adjusted_uuid and adjusted_by_uuids (chain navigation in both directions), adjustment_reason, and receipt_printed (always-emitted boolean for "paid bills not yet printed"). Aggregations sort their output so the same logical state produces bytewise-identical documents across reindexes — Bill.payments / Bill.discounts / Bill.adjustedBy are HashSets, so without the sort the document bytes drift on every save. A new resource type billing_bill_discount is also indexed end-to-end (serializer + AOP advice on saveBillDiscount + ResourceTypeProvider + Spring wiring). It exists so the "approval queue" question — show me all pending discounts requiring my review — can be answered with one patient-scoped query against the discount documents, rather than scanning every bill's denormalized discount_statuses aggregate. The discount document also carries the canonical discount_amount (computed money figure) alongside discount_value (raw input) so callers can distinguish "find 15% discounts" from "find discounts > $50". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BillDiscountIndexingAdvice.java | 50 ++++ .../BillDiscountRecordSerializer.java | 101 +++++++ .../BillDiscountResourceTypeProvider.java | 38 +++ .../api/querystore/BillRecordSerializer.java | 83 ++++++ .../BillingQueryStoreConstants.java | 38 +++ .../resources/moduleApplicationContext.xml | 10 + .../BillDiscountRecordSerializerTest.java | 193 +++++++++++++ .../querystore/BillRecordSerializerTest.java | 257 +++++++++++++++++- .../querystore/IndexingAdviceConfigTest.java | 13 + omod/src/main/resources/config.xml | 5 + 10 files changed, 787 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java create mode 100644 api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java new file mode 100644 index 00000000..6b0ec14a --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.util.Collections; +import java.util.Set; + +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.model.BillDiscount; +import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice; + +public class BillDiscountIndexingAdvice extends AbstractIndexingAdvice { + + // BillDiscountService exposes only saveBillDiscount — approve / reject / void all happen by + // mutating the entity and calling saveBillDiscount, routed through AbstractIndexingAdvice's + // per-node voided policy (voided rows go to delete on the resave path). No purge method + // exists, so PURGE_METHODS is empty rather than aspirationally listing a name AOP can never + // match — see IndexingAdviceConfigTest. + static final Set TRIGGER_METHODS = Collections.singleton("saveBillDiscount"); + + static final Set PURGE_METHODS = Collections.emptySet(); + + @Override + protected Class getSupportedType() { + return BillDiscount.class; + } + + @Override + protected BillDiscountRecordSerializer serializer() { + return Context.getRegisteredComponent("billing.querystore.serializer.bill_discount", + BillDiscountRecordSerializer.class); + } + + @Override + protected Set triggerMethods() { + return TRIGGER_METHODS; + } + + @Override + protected Set purgeMethods() { + return PURGE_METHODS; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java new file mode 100644 index 00000000..49889bb8 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java @@ -0,0 +1,101 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import org.openmrs.Patient; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillDiscount; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.DiscountStatus; +import org.openmrs.module.billing.api.model.DiscountType; +import org.openmrs.module.querystore.model.QueryDocument; +import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; +import org.openmrs.module.querystore.util.DateFormatUtil; + +public class BillDiscountRecordSerializer extends AbstractRecordSerializer { + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_DISCOUNT; + } + + @Override + public Class getSupportedType() { + return BillDiscount.class; + } + + @Override + protected String getPatientUuid(BillDiscount discount) { + Bill bill = discount.getBill(); + if (bill == null) { + return null; + } + Patient patient = bill.getPatient(); + return patient != null ? patient.getUuid() : null; + } + + @Override + protected String getResourceUuid(BillDiscount discount) { + return discount.getUuid(); + } + + @Override + protected LocalDate getDate(BillDiscount discount) { + return DateFormatUtil.toLocalDate(discount.getDateCreated()); + } + + @Override + protected void populate(BillDiscount discount, QueryDocument doc) { + Bill bill = discount.getBill(); + if (bill == null || bill.getPatient() == null) { + return; + } + // Defensive: if discountType or discountValue is null the validator should have rejected + // the row, but the indexing advice swallows RuntimeException per-entity and would silently + // drop the discount from the approval queue. Skip the document rather than NPE inside + // getDiscountAmount(). + if (discount.getDiscountType() == null || discount.getDiscountValue() == null) { + return; + } + + DiscountStatus status = discount.getStatus(); + DiscountType type = discount.getDiscountType(); + BigDecimal value = discount.getDiscountValue(); + BigDecimal amount = discount.getDiscountAmount(); + String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid(); + + doc.setText(String.format("Discount on bill %s. Status: %s. Type: %s. Value: %s. Amount: %s. Reason: %s.", + receiptOrUuid, status != null ? status.name() : "UNKNOWN", type.name(), value.toPlainString(), + amount.toPlainString(), discount.getJustification() != null ? discount.getJustification() : "")); + + doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_UUID, bill.getUuid()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null); + doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_TYPE, type.name()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_VALUE, value); + doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_AMOUNT, amount); + doc.putMetadata(BillingQueryStoreConstants.FIELD_JUSTIFICATION, discount.getJustification()); + doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, discount.getVoided()); + + BillLineItem lineItem = discount.getLineItem(); + if (lineItem != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID, lineItem.getUuid()); + } + if (discount.getInitiator() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_INITIATOR_UUID, discount.getInitiator().getUuid()); + } + if (discount.getApprover() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_APPROVER_UUID, discount.getApprover().getUuid()); + } + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java new file mode 100644 index 00000000..9fc9d05f --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import org.openmrs.module.querystore.bootstrap.TypeBootstrapper; +import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer; +import org.openmrs.module.querystore.spi.ResourceTypeProvider; + +public class BillDiscountResourceTypeProvider implements ResourceTypeProvider { + + private final BillDiscountRecordSerializer serializer; + + public BillDiscountResourceTypeProvider(BillDiscountRecordSerializer serializer) { + this.serializer = serializer; + } + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_DISCOUNT; + } + + @Override + public ClinicalRecordSerializer getSerializer() { + return serializer; + } + + @Override + public TypeBootstrapper getBootstrapper() { + return null; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java index 6f20fbcc..99f6e402 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java @@ -13,12 +13,17 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.TreeSet; import org.openmrs.Patient; import org.openmrs.Visit; import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillDiscount; import org.openmrs.module.billing.api.model.BillLineItem; import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.Payment; +import org.openmrs.module.billing.api.model.PaymentMode; import org.openmrs.module.querystore.model.QueryDocument; import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; import org.openmrs.module.querystore.util.DateFormatUtil; @@ -100,6 +105,31 @@ protected void populate(Bill bill, QueryDocument doc) { if (visit != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_VISIT_UUID, visit.getUuid()); } + + List paymentModes = collectPaymentModes(bill); + if (!paymentModes.isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_PAYMENT_MODES, paymentModes); + } + List discountStatuses = collectDiscountStatuses(bill); + if (!discountStatuses.isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES, discountStatuses); + } + if (bill.getBillAdjusted() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID, bill.getBillAdjusted().getUuid()); + } + List adjustedByUuids = collectAdjustedByUuids(bill); + if (!adjustedByUuids.isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS, adjustedByUuids); + } + if (bill.getAdjustmentReason() != null && !bill.getAdjustmentReason().trim().isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON, bill.getAdjustmentReason()); + } + // Boolean.TRUE.equals normalizes null → false. Persisted bills always have a value + // (Bill.hbm.xml: not-null, defaults to false), so this only matters for hand-built bills + // in tests or for in-flight bills constructed via the builder paths. Always-emit pattern + // lets consumers write "paid bills not yet printed" as a single term filter, no + // exists-clause. + doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED, Boolean.TRUE.equals(bill.getReceiptPrinted())); } private List collectLineItemNames(Bill bill) { @@ -118,4 +148,57 @@ private List collectLineItemNames(Bill bill) { } return names; } + + // Distinct + sorted. Bill.payments and Bill.discounts are Set<>s, so iteration order is + // non-deterministic — sorting gives consumers a stable list for snapshot / cache use without + // committing to any source-side ordering contract. Also keeps the resulting bill document + // bytewise-identical across reindexes of the same logical state. + private List collectPaymentModes(Bill bill) { + Set modes = new TreeSet<>(); + if (bill.getPayments() == null) { + return new ArrayList<>(modes); + } + for (Payment payment : bill.getPayments()) { + if (payment == null || payment.getVoided()) { + continue; + } + PaymentMode mode = payment.getInstanceType(); + // Whitespace-only names slip past isEmpty(); a tender mode literally named " " + // would otherwise show up in the indexed list between Cash and Mobile Money. + if (mode != null && mode.getName() != null && !mode.getName().trim().isEmpty()) { + modes.add(mode.getName()); + } + } + return new ArrayList<>(modes); + } + + private List collectDiscountStatuses(Bill bill) { + Set statuses = new TreeSet<>(); + if (bill.getDiscounts() == null) { + return new ArrayList<>(statuses); + } + for (BillDiscount discount : bill.getDiscounts()) { + if (discount == null || discount.getVoided() || discount.getStatus() == null) { + continue; + } + statuses.add(discount.getStatus().name()); + } + return new ArrayList<>(statuses); + } + + private List collectAdjustedByUuids(Bill bill) { + // Sorted for the same reason payment_modes / discount_statuses are: Bill.adjustedBy is a + // HashSet, so iteration order is non-deterministic. Without the sort, the same logical + // state would emit different document bytes across reindexes. + Set uuids = new TreeSet<>(); + if (bill.getAdjustedBy() == null) { + return new ArrayList<>(uuids); + } + for (Bill adjuster : bill.getAdjustedBy()) { + if (adjuster != null && adjuster.getUuid() != null) { + uuids.add(adjuster.getUuid()); + } + } + return new ArrayList<>(uuids); + } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java index 9819625e..164dda4f 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java @@ -20,6 +20,8 @@ final class BillingQueryStoreConstants { static final String RESOURCE_TYPE_BILL_REFUND = "billing_bill_refund"; + static final String RESOURCE_TYPE_BILL_DISCOUNT = "billing_bill_discount"; + static final String FIELD_RECEIPT_NUMBER = "receipt_number"; static final String FIELD_BILL_UUID = "bill_uuid"; @@ -66,6 +68,42 @@ final class BillingQueryStoreConstants { static final String FIELD_COMPLETER_UUID = "completer_uuid"; + // Distinct non-voided PaymentMode.name values across the bill's payments. Lets ops queries + // like "settlements by tender type" succeed without scanning every payment row. + static final String FIELD_PAYMENT_MODES = "payment_modes"; + + // Distinct non-voided DiscountStatus values on the bill, sorted alphabetically (NOT workflow + // order — APPROVED comes before PENDING). The aggregate exists for presence queries ("which + // bills have a pending discount?") — consumers must not treat the list as a timeline; the + // BillDiscount resource type carries the per-discount detail when ordering matters. + static final String FIELD_DISCOUNT_STATUSES = "discount_statuses"; + + // UUID of the bill this one adjusts (if any). Together with FIELD_ADJUSTED_BY_UUIDS, lets a + // query trace the adjustment chain in either direction. + static final String FIELD_BILL_ADJUSTED_UUID = "bill_adjusted_uuid"; + + static final String FIELD_ADJUSTED_BY_UUIDS = "adjusted_by_uuids"; + + static final String FIELD_ADJUSTMENT_REASON = "adjustment_reason"; + + static final String FIELD_RECEIPT_PRINTED = "receipt_printed"; + + // BillDiscount fields. The BillDiscount document is keyed to its parent bill's patient so the + // "approval queue" question — "show me all pending discounts requiring my review" — is a + // patient-scoped search per the querystore SPI contract. + static final String FIELD_DISCOUNT_TYPE = "discount_type"; + + // Raw input — a percentage (e.g., 15 for "15% off") when discount_type=PERCENTAGE, a money + // amount when discount_type=FIXED_AMOUNT. Always paired with discount_type to be meaningful. + static final String FIELD_DISCOUNT_VALUE = "discount_value"; + + // Computed money figure — the actual currency amount removed from the bill, derived from + // (value, type, current base). Use this for "find discounts > $50" queries; use discount_value + // for "find 15% discounts" queries. + static final String FIELD_DISCOUNT_AMOUNT = "discount_amount"; + + static final String FIELD_JUSTIFICATION = "justification"; + private BillingQueryStoreConstants() { } } diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index a6325367..a43d62cf 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -355,6 +355,10 @@ class="org.openmrs.module.billing.api.querystore.BillRefundRecordSerializer" lazy-init="true"/> + + @@ -367,6 +371,12 @@ + + + + diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java new file mode 100644 index 00000000..07c04550 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java @@ -0,0 +1,193 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.math.BigDecimal; +import java.util.Date; + +import org.junit.jupiter.api.Test; +import org.openmrs.Patient; +import org.openmrs.User; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillDiscount; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.DiscountStatus; +import org.openmrs.module.billing.api.model.DiscountType; +import org.openmrs.module.querystore.model.QueryDocument; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class BillDiscountRecordSerializerTest { + + private static final String DISCOUNT_UUID = "discount-uuid-1"; + + private static final String BILL_UUID = "bill-uuid-1"; + + private static final String PATIENT_UUID = "patient-uuid-1"; + + private final BillDiscountRecordSerializer serializer = new BillDiscountRecordSerializer(); + + @Test + public void serialize_shouldSetCoreFieldsFromDiscount() { + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("25.00"), DiscountStatus.PENDING); + + QueryDocument doc = serializer.serialize(discount); + + assertNotNull(doc); + assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_BILL_DISCOUNT, doc.getResourceType()); + assertEquals(DISCOUNT_UUID, doc.getResourceUuid()); + assertEquals(PATIENT_UUID, doc.getPatientUuid(), + "patientUuid must come from the parent bill so the approval-queue query stays patient-scoped"); + assertNotNull(doc.getDate()); + } + + @Test + public void serialize_shouldEmitDiscountSpecificMetadata() { + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("25.00"), DiscountStatus.APPROVED); + discount.setJustification("Charity-eligible patient"); + + QueryDocument doc = serializer.serialize(discount); + + assertNotNull(doc); + assertEquals(BILL_UUID, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_UUID)); + assertEquals("APPROVED", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_STATUS)); + assertEquals("FIXED_AMOUNT", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_TYPE)); + assertEquals(new BigDecimal("25.00"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_VALUE)); + assertEquals(new BigDecimal("25.00"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_AMOUNT)); + assertEquals("Charity-eligible patient", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_JUSTIFICATION)); + } + + @Test + public void serialize_shouldIncludeLineItemUuidWhenLineScoped() { + BillDiscount discount = newDiscount(DiscountType.PERCENTAGE, new BigDecimal("10"), DiscountStatus.APPROVED); + BillLineItem lineItem = new BillLineItem(); + lineItem.setUuid("line-item-uuid-1"); + lineItem.setPrice(new BigDecimal("50.00")); + lineItem.setQuantity(2); + lineItem.setVoided(false); + discount.setLineItem(lineItem); + + QueryDocument doc = serializer.serialize(discount); + + assertNotNull(doc); + assertEquals("line-item-uuid-1", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID)); + } + + @Test + public void serialize_shouldIncludeInitiatorAndApprover() { + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.APPROVED); + discount.setInitiator(userWithUuid("initiator-uuid")); + discount.setApprover(userWithUuid("approver-uuid")); + + QueryDocument doc = serializer.serialize(discount); + + assertNotNull(doc); + assertEquals("initiator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_INITIATOR_UUID)); + assertEquals("approver-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_APPROVER_UUID)); + } + + @Test + public void serialize_shouldOmitApproverWhenStillPending() { + // PENDING discounts haven't been approved yet — the approver_uuid field must be absent + // (not an empty string) so a "find unassigned approvals" query can use a missing-field + // filter rather than equality against an empty value. + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.PENDING); + discount.setInitiator(userWithUuid("initiator-uuid")); + + QueryDocument doc = serializer.serialize(discount); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_APPROVER_UUID)); + } + + @Test + public void serialize_shouldEmitTextBlobIncludingJustification() { + BillDiscount discount = newDiscount(DiscountType.PERCENTAGE, new BigDecimal("15"), DiscountStatus.APPROVED); + discount.setJustification("Charity-eligible patient"); + + QueryDocument doc = serializer.serialize(discount); + + assertNotNull(doc); + assertTrue(doc.getText().contains("Discount on bill R-100."), doc.getText()); + assertTrue(doc.getText().contains("Status: APPROVED."), doc.getText()); + assertTrue(doc.getText().contains("Type: PERCENTAGE."), doc.getText()); + assertTrue(doc.getText().contains("Reason: Charity-eligible patient."), doc.getText()); + } + + @Test + public void serialize_shouldReturnNullWhenBillAbsent() { + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.PENDING); + discount.setBill(null); + + QueryDocument doc = serializer.serialize(discount); + + assertNull(doc); + } + + @Test + public void serialize_shouldReturnNullWhenPatientAbsent() { + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.PENDING); + discount.getBill().setPatient(null); + + QueryDocument doc = serializer.serialize(discount); + + assertNull(doc); + } + + @Test + public void serialize_shouldReturnNullWhenDiscountTypeAbsent() { + // Discount type drives getDiscountAmount(); a partially constructed row (validator gap) + // would NPE inside that derivation. The advice swallows the NPE per-entity and would + // silently drop the row from the approval queue — better to skip with a null doc here. + BillDiscount discount = newDiscount(null, new BigDecimal("10"), DiscountStatus.PENDING); + + QueryDocument doc = serializer.serialize(discount); + + assertNull(doc); + } + + @Test + public void serialize_shouldReturnNullWhenDiscountValueAbsent() { + BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, null, DiscountStatus.PENDING); + + QueryDocument doc = serializer.serialize(discount); + + assertNull(doc); + } + + private BillDiscount newDiscount(DiscountType type, BigDecimal value, DiscountStatus status) { + Bill bill = new Bill(); + bill.setUuid(BILL_UUID); + bill.setReceiptNumber("R-100"); + Patient patient = new Patient(); + patient.setUuid(PATIENT_UUID); + bill.setPatient(patient); + + BillDiscount discount = new BillDiscount(); + discount.setUuid(DISCOUNT_UUID); + discount.setBill(bill); + discount.setDiscountType(type); + discount.setDiscountValue(value); + discount.setStatus(status); + discount.setVoided(false); + discount.setDateCreated(new Date()); + return discount; + } + + private User userWithUuid(String uuid) { + User user = new User(); + user.setUuid(uuid); + return user; + } +} diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java index 749987f4..1b7692a3 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java @@ -28,6 +28,8 @@ import org.openmrs.module.billing.api.model.CashPoint; import org.openmrs.module.billing.api.model.DiscountStatus; import org.openmrs.module.billing.api.model.DiscountType; +import org.openmrs.module.billing.api.model.Payment; +import org.openmrs.module.billing.api.model.PaymentMode; import org.openmrs.module.querystore.model.QueryDocument; import org.openmrs.module.stockmanagement.api.model.StockItem; @@ -270,6 +272,236 @@ public void serialize_shouldHandleNullLineItemsCollection() { assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); } + @Test + public void serialize_shouldEmitDistinctSortedPaymentModes() { + // "Settlements by tender type" is an ops/finance query. The list must be distinct (two + // Mobile Money payments don't double-count) and exact-match-queryable (List, not + // comma-joined). Sorted because the underlying HashSet has no stable iteration + // order — sorting gives consumers bytewise-identical docs across reindexes. + Bill bill = postedBillWithLineItem("R-200", new BigDecimal("100"), 1); + addPayment(bill, new BigDecimal("30"), paymentMode("Mobile Money")); + addPayment(bill, new BigDecimal("40"), paymentMode("Cash")); + addPayment(bill, new BigDecimal("30"), paymentMode("Mobile Money")); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("Cash", "Mobile Money"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES)); + } + + @Test + public void serialize_shouldSkipPaymentsWithMissingMode() { + // A payment row can lack a useful mode (legacy data, half-constructed, blank name). Without + // the guards the serializer would NPE or emit a blank/whitespace entry, polluting the text + // blob and the metadata list — a tender mode literally named " " would otherwise show up + // between Cash and Mobile Money on every ops dashboard. + Bill bill = postedBillWithLineItem("R-212", new BigDecimal("100"), 1); + Payment noMode = new Payment(); + noMode.setAmount(new BigDecimal("10")); + noMode.setAmountTendered(new BigDecimal("10")); + noMode.setVoided(false); + bill.setPayments(new HashSet<>()); + bill.getPayments().add(noMode); + PaymentMode blank = new PaymentMode(); + blank.setName(""); + addPayment(bill, new BigDecimal("5"), blank); + PaymentMode whitespace = new PaymentMode(); + whitespace.setName(" "); + addPayment(bill, new BigDecimal("5"), whitespace); + addPayment(bill, new BigDecimal("20"), paymentMode("Cash")); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("Cash"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES)); + } + + @Test + public void serialize_shouldExcludeVoidedPaymentsFromPaymentModes() { + Bill bill = postedBillWithLineItem("R-201", new BigDecimal("100"), 1); + addPayment(bill, new BigDecimal("30"), paymentMode("Cash")); + Payment voided = new Payment(); + voided.setAmount(new BigDecimal("20")); + voided.setAmountTendered(new BigDecimal("20")); + voided.setVoided(true); + voided.setInstanceType(paymentMode("Mobile Money")); + bill.getPayments().add(voided); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("Cash"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES)); + } + + @Test + public void serialize_shouldOmitPaymentModesMetadataWhenNoNonVoidedPayments() { + Bill bill = postedBillWithLineItem("R-202", new BigDecimal("100"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_PAYMENT_MODES)); + } + + @Test + public void serialize_shouldEmitDistinctDiscountStatuses() { + // "Which bills have a pending discount?" is the workflow-queue question this enables. + // Voided discounts must not pollute the field — a rejected then re-issued discount + // shouldn't show its old rejected status. + Bill bill = postedBillWithLineItem("R-203", new BigDecimal("100"), 1); + addDiscount(bill, DiscountStatus.PENDING, false); + addDiscount(bill, DiscountStatus.APPROVED, false); + addDiscount(bill, DiscountStatus.PENDING, false); + addDiscount(bill, DiscountStatus.REJECTED, true); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + @SuppressWarnings("unchecked") + List statuses = (List) doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES); + assertNotNull(statuses); + assertEquals(2, statuses.size()); + assertTrue(statuses.contains("PENDING")); + assertTrue(statuses.contains("APPROVED")); + assertFalse(statuses.contains("REJECTED")); + } + + @Test + public void serialize_shouldSkipDiscountsWithNullStatus() { + // A half-constructed discount may reach the serializer with status still null. Indexing + // the literal string "null" or NPE'ing inside the loop would silently drop the bill from + // the index via the indexing advice's per-entity exception swallow. + Bill bill = postedBillWithLineItem("R-220", new BigDecimal("100"), 1); + addDiscount(bill, null, false); + addDiscount(bill, DiscountStatus.PENDING, false); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("PENDING"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES)); + } + + @Test + public void serialize_shouldOmitDiscountStatusesMetadataWhenNoNonVoidedDiscounts() { + Bill bill = postedBillWithLineItem("R-204", new BigDecimal("100"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES)); + } + + @Test + public void serialize_shouldIncludeBillAdjustedUuidWhenBillAdjustsAnother() { + Bill original = new Bill(); + original.setUuid("original-bill-uuid"); + Bill bill = postedBillWithLineItem("R-205", new BigDecimal("50"), 1); + bill.setBillAdjusted(original); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals("original-bill-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID)); + } + + @Test + public void serialize_shouldIncludeAdjustedByUuidsWhenBillHasBeenAdjusted() { + Bill bill = postedBillWithLineItem("R-206", new BigDecimal("50"), 1); + Bill adjusterA = new Bill(); + adjusterA.setUuid("adjuster-a"); + Bill adjusterB = new Bill(); + adjusterB.setUuid("adjuster-b"); + bill.setAdjustedBy(new HashSet<>(Arrays.asList(adjusterA, adjusterB))); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + @SuppressWarnings("unchecked") + List uuids = (List) doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS); + assertNotNull(uuids); + assertEquals(2, uuids.size()); + assertTrue(uuids.contains("adjuster-a")); + assertTrue(uuids.contains("adjuster-b")); + } + + @Test + public void serialize_shouldOmitAdjustmentFieldsWhenAbsent() { + Bill bill = postedBillWithLineItem("R-207", new BigDecimal("50"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON)); + } + + @Test + public void serialize_shouldIncludeAdjustmentReasonWhenSet() { + Bill bill = postedBillWithLineItem("R-208", new BigDecimal("50"), 1); + bill.setAdjustmentReason("Patient was overcharged for consult"); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals("Patient was overcharged for consult", + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON)); + } + + @Test + public void serialize_shouldOmitAdjustmentReasonWhenEmpty() { + // An empty-string reason is semantically equivalent to "no reason given" — emitting it + // would pollute "find bills with an adjustment reason" exists-queries with empty matches. + Bill bill = postedBillWithLineItem("R-208a", new BigDecimal("50"), 1); + bill.setAdjustmentReason(""); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON)); + } + + @Test + public void serialize_shouldEmitAdjustedByUuidsSorted() { + // Bill.adjustedBy is a HashSet — without sorting, the same logical state would emit + // different document bytes across reindexes, breaking snapshot/cache stability. + Bill bill = postedBillWithLineItem("R-208b", new BigDecimal("50"), 1); + Bill adjusterC = new Bill(); + adjusterC.setUuid("z-uuid"); + Bill adjusterA = new Bill(); + adjusterA.setUuid("a-uuid"); + Bill adjusterB = new Bill(); + adjusterB.setUuid("m-uuid"); + bill.setAdjustedBy(new HashSet<>(Arrays.asList(adjusterC, adjusterA, adjusterB))); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("a-uuid", "m-uuid", "z-uuid"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS)); + } + + @Test + public void serialize_shouldAlwaysEmitReceiptPrintedAsBoolean() { + // Always present so consumers can build "paid bills not yet printed" queries with a single + // term filter, no exists-clause. Null on the entity is normalized to false — a half-saved + // bill that never set the flag is treated as not-printed, which is the safer default. + Bill notPrinted = postedBillWithLineItem("R-209", new BigDecimal("50"), 1); + notPrinted.setReceiptPrinted(false); + Bill printed = postedBillWithLineItem("R-210", new BigDecimal("50"), 1); + printed.setReceiptPrinted(true); + Bill unset = postedBillWithLineItem("R-211", new BigDecimal("50"), 1); + unset.setReceiptPrinted(null); + + assertEquals(Boolean.FALSE, + serializer.serialize(notPrinted).getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED)); + assertEquals(Boolean.TRUE, + serializer.serialize(printed).getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED)); + assertEquals(Boolean.FALSE, + serializer.serialize(unset).getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED)); + } + @Test public void serialize_shouldReturnNullWhenPatientAbsent() { // Without a patient, the serializer cannot produce a document keyed to a patient. Returning @@ -332,13 +564,36 @@ private void addApprovedDiscount(Bill bill, BigDecimal amount) { } private void addPayment(Bill bill, BigDecimal amount) { + addPayment(bill, amount, null); + } + + private void addPayment(Bill bill, BigDecimal amount, PaymentMode mode) { if (bill.getPayments() == null) { bill.setPayments(new HashSet<>()); } - org.openmrs.module.billing.api.model.Payment payment = new org.openmrs.module.billing.api.model.Payment(); + Payment payment = new Payment(); payment.setAmount(amount); payment.setAmountTendered(amount); payment.setVoided(false); + payment.setInstanceType(mode); bill.getPayments().add(payment); } + + private static PaymentMode paymentMode(String name) { + PaymentMode mode = new PaymentMode(); + mode.setName(name); + return mode; + } + + private void addDiscount(Bill bill, DiscountStatus status, boolean voided) { + if (bill.getDiscounts() == null) { + bill.setDiscounts(new HashSet<>()); + } + BillDiscount discount = new BillDiscount(); + discount.setDiscountType(DiscountType.FIXED_AMOUNT); + discount.setDiscountValue(new BigDecimal("5")); + discount.setStatus(status); + discount.setVoided(voided); + bill.getDiscounts().add(discount); + } } diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java index a48ce82f..41054a65 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java @@ -13,6 +13,7 @@ import java.util.Set; import org.junit.jupiter.api.Test; +import org.openmrs.module.billing.api.BillDiscountService; import org.openmrs.module.billing.api.BillRefundService; import org.openmrs.module.billing.api.BillService; @@ -47,6 +48,18 @@ public void billRefundIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods( "BillRefundIndexingAdvice"); } + @Test + public void billDiscountIndexingAdvice_triggerMethodsShouldAllExistOnBillDiscountService() { + assertAllMethodsExist(BillDiscountIndexingAdvice.TRIGGER_METHODS, BillDiscountService.class, + "BillDiscountIndexingAdvice"); + } + + @Test + public void billDiscountIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() { + assertSubset(BillDiscountIndexingAdvice.PURGE_METHODS, BillDiscountIndexingAdvice.TRIGGER_METHODS, + "BillDiscountIndexingAdvice"); + } + private static void assertAllMethodsExist(Set methodNames, Class iface, String adviceName) { for (String name : methodNames) { assertTrue(hasMethod(iface, name), adviceName + " references method '" + name + "' but no such method exists on " diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index 6603491c..5c80563c 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -53,6 +53,11 @@ org.openmrs.module.billing.api.querystore.BillRefundIndexingAdvice + + org.openmrs.module.billing.api.BillDiscountService + org.openmrs.module.billing.api.querystore.BillDiscountIndexingAdvice + + org.openmrs.module.billing.BillingModuleActivator From e9e2c1004c51223f26c106227a5119a428fd7f05 Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 18:13:41 +0300 Subject: [PATCH 7/9] Index workflow status, audit columns, and timesheets into the querystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bill document now carries the workflow and clinical-link data the prior indexes were missing: line_item_statuses lets "find bills with a REFUND_REQUESTED line item" succeed without scanning every line item row; order_uuids closes the clinical-to-billing trace ("find the bill for this lab order"); cashier_name denormalizes Provider.getName() so admin queries don't pay a second lookup; payment_mode_amounts ships parallel to payment_modes (same TreeMap alphabetic order) so consumers can zip the two arrays at query time to answer "total Cash collected this week" — payments of the same mode are summed into one entry. A shared BillingAuditFields helper centralises the OpenMRS audit columns (created_at, date_changed, date_voided, creator_uuid, changed_by_uuid, voided_by_uuid, void_reason) across the four BaseOpenmrsData resource types — emitting them inline would invite drift where a future refactor adds the field to one serializer and forgets the others, and audit queries silently miss the new type. The fifth indexed resource type billing_timesheet ships end-to-end: TimesheetRecordSerializer (provider-scoped — patientUuid is null by design), TimesheetIndexingAdvice (triggers on the generic IEntityDataService surface: save / voidEntity / unvoidEntity / purge), TimesheetResourceTypeProvider, Spring wiring, advice point. It lets "who was on duty between 2pm-3pm" succeed by querying clock_in / clock_out without scanning every timesheet row. BillableService catalog indexing was attempted but dropped: AbstractIndexingAdvice rules it out at the type-bound level. The querystore SPI is patient-data-scoped by design; widening the bound to admit BaseChangeableOpenmrsMetadata is a querystore change, not a billing change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BillDiscountRecordSerializer.java | 2 + .../api/querystore/BillRecordSerializer.java | 90 ++++++-- .../BillRefundRecordSerializer.java | 2 + .../api/querystore/BillingAuditFields.java | 48 +++++ .../BillingQueryStoreConstants.java | 46 ++++ .../querystore/TimesheetIndexingAdvice.java | 52 +++++ .../querystore/TimesheetRecordSerializer.java | 91 ++++++++ .../TimesheetResourceTypeProvider.java | 38 ++++ .../resources/moduleApplicationContext.xml | 10 + .../querystore/BillRecordSerializerTest.java | 200 ++++++++++++++++++ .../querystore/IndexingAdviceConfigTest.java | 12 ++ .../TimesheetRecordSerializerTest.java | 142 +++++++++++++ omod/src/main/resources/config.xml | 5 + 13 files changed, 723 insertions(+), 15 deletions(-) create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java create mode 100644 api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java index 49889bb8..ea21e209 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java @@ -97,5 +97,7 @@ protected void populate(BillDiscount discount, QueryDocument doc) { if (discount.getApprover() != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_APPROVER_UUID, discount.getApprover().getUuid()); } + + BillingAuditFields.populate(doc, discount); } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java index 99f6e402..bfbaba2c 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java @@ -13,14 +13,18 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; +import org.openmrs.Order; import org.openmrs.Patient; import org.openmrs.Visit; import org.openmrs.module.billing.api.model.Bill; import org.openmrs.module.billing.api.model.BillDiscount; import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.BillLineItemStatus; import org.openmrs.module.billing.api.model.BillStatus; import org.openmrs.module.billing.api.model.Payment; import org.openmrs.module.billing.api.model.PaymentMode; @@ -96,6 +100,9 @@ protected void populate(Bill bill, QueryDocument doc) { if (bill.getCashier() != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_UUID, bill.getCashier().getUuid()); + if (bill.getCashier().getName() != null && !bill.getCashier().getName().trim().isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_NAME, bill.getCashier().getName()); + } } if (bill.getCashPoint() != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID, bill.getCashPoint().getUuid()); @@ -106,14 +113,29 @@ protected void populate(Bill bill, QueryDocument doc) { doc.putMetadata(BillingQueryStoreConstants.FIELD_VISIT_UUID, visit.getUuid()); } - List paymentModes = collectPaymentModes(bill); - if (!paymentModes.isEmpty()) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_PAYMENT_MODES, paymentModes); + Map paymentTotalsByMode = collectPaymentTotalsByMode(bill); + if (!paymentTotalsByMode.isEmpty()) { + // payment_modes and payment_mode_amounts are emitted in parallel TreeMap iteration + // order (alphabetic) so consumers can zip the two arrays at query time. + doc.putMetadata(BillingQueryStoreConstants.FIELD_PAYMENT_MODES, new ArrayList<>(paymentTotalsByMode.keySet())); + List amounts = new ArrayList<>(paymentTotalsByMode.size()); + for (BigDecimal amount : paymentTotalsByMode.values()) { + amounts.add(amount.toPlainString()); + } + doc.putMetadata(BillingQueryStoreConstants.FIELD_PAYMENT_MODE_AMOUNTS, amounts); } List discountStatuses = collectDiscountStatuses(bill); if (!discountStatuses.isEmpty()) { doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES, discountStatuses); } + List lineItemStatuses = collectLineItemStatuses(bill); + if (!lineItemStatuses.isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_LINE_ITEM_STATUSES, lineItemStatuses); + } + List orderUuids = collectOrderUuids(bill); + if (!orderUuids.isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_ORDER_UUIDS, orderUuids); + } if (bill.getBillAdjusted() != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID, bill.getBillAdjusted().getUuid()); } @@ -130,6 +152,8 @@ protected void populate(Bill bill, QueryDocument doc) { // lets consumers write "paid bills not yet printed" as a single term filter, no // exists-clause. doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED, Boolean.TRUE.equals(bill.getReceiptPrinted())); + + BillingAuditFields.populate(doc, bill); } private List collectLineItemNames(Bill bill) { @@ -149,27 +173,29 @@ private List collectLineItemNames(Bill bill) { return names; } - // Distinct + sorted. Bill.payments and Bill.discounts are Set<>s, so iteration order is - // non-deterministic — sorting gives consumers a stable list for snapshot / cache use without - // committing to any source-side ordering contract. Also keeps the resulting bill document - // bytewise-identical across reindexes of the same logical state. - private List collectPaymentModes(Bill bill) { - Set modes = new TreeSet<>(); + // Distinct, sorted by mode name, with each mode's non-voided amount summed. Bill.payments is + // a HashSet (non-deterministic iteration); TreeMap keeps the (modes, amounts) parallel pair + // in a stable alphabetic order so the resulting bill document is bytewise-identical across + // reindexes of the same logical state. A bill with two Cash payments emits one entry whose + // amount is their sum; whitespace-only mode names are skipped (they would otherwise show up + // as " " between Cash and Mobile Money on every ops dashboard). + private Map collectPaymentTotalsByMode(Bill bill) { + Map totals = new TreeMap<>(); if (bill.getPayments() == null) { - return new ArrayList<>(modes); + return totals; } for (Payment payment : bill.getPayments()) { if (payment == null || payment.getVoided()) { continue; } PaymentMode mode = payment.getInstanceType(); - // Whitespace-only names slip past isEmpty(); a tender mode literally named " " - // would otherwise show up in the indexed list between Cash and Mobile Money. - if (mode != null && mode.getName() != null && !mode.getName().trim().isEmpty()) { - modes.add(mode.getName()); + if (mode == null || mode.getName() == null || mode.getName().trim().isEmpty()) { + continue; } + BigDecimal amount = payment.getAmountTendered() != null ? payment.getAmountTendered() : BigDecimal.ZERO; + totals.merge(mode.getName(), amount, BigDecimal::add); } - return new ArrayList<>(modes); + return totals; } private List collectDiscountStatuses(Bill bill) { @@ -186,6 +212,40 @@ private List collectDiscountStatuses(Bill bill) { return new ArrayList<>(statuses); } + private List collectLineItemStatuses(Bill bill) { + Set statuses = new TreeSet<>(); + if (bill.getLineItems() == null) { + return new ArrayList<>(statuses); + } + for (BillLineItem lineItem : bill.getLineItems()) { + if (lineItem == null || lineItem.getVoided()) { + continue; + } + BillLineItemStatus status = lineItem.getStatus(); + if (status != null) { + statuses.add(status.name()); + } + } + return new ArrayList<>(statuses); + } + + private List collectOrderUuids(Bill bill) { + Set uuids = new TreeSet<>(); + if (bill.getLineItems() == null) { + return new ArrayList<>(uuids); + } + for (BillLineItem lineItem : bill.getLineItems()) { + if (lineItem == null || lineItem.getVoided()) { + continue; + } + Order order = lineItem.getOrder(); + if (order != null && order.getUuid() != null) { + uuids.add(order.getUuid()); + } + } + return new ArrayList<>(uuids); + } + private List collectAdjustedByUuids(Bill bill) { // Sorted for the same reason payment_modes / discount_statuses are: Bill.adjustedBy is a // HashSet, so iteration order is non-deterministic. Without the sort, the same logical diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java index f5f7ac9f..381e81da 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java @@ -108,5 +108,7 @@ protected void populate(BillRefund refund, QueryDocument doc) { if (refund.getCompleter() != null) { doc.putMetadata(BillingQueryStoreConstants.FIELD_COMPLETER_UUID, refund.getCompleter().getUuid()); } + + BillingAuditFields.populate(doc, refund); } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java new file mode 100644 index 00000000..98c38c7b --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java @@ -0,0 +1,48 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import org.openmrs.BaseOpenmrsData; +import org.openmrs.module.querystore.model.QueryDocument; + +// OpenMRS BaseOpenmrsData audit columns shared by Bill / BillRefund / BillDiscount / Timesheet. +// Centralised because all four resource types want the same six audit fields (creator, changedBy, +// dateChanged, voidedBy, dateVoided, voidReason) plus the time-precise createdAt; emitting them +// inline would invite drift — a future refactor adds the field to one serializer and forgets the +// others, and audit queries silently miss the new resource type. +final class BillingAuditFields { + + private BillingAuditFields() { + } + + static void populate(QueryDocument doc, BaseOpenmrsData entity) { + if (entity.getDateCreated() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CREATED_AT, entity.getDateCreated()); + } + if (entity.getDateChanged() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_CHANGED, entity.getDateChanged()); + } + if (entity.getDateVoided() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_VOIDED, entity.getDateVoided()); + } + if (entity.getCreator() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CREATOR_UUID, entity.getCreator().getUuid()); + } + if (entity.getChangedBy() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CHANGED_BY_UUID, entity.getChangedBy().getUuid()); + } + if (entity.getVoidedBy() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED_BY_UUID, entity.getVoidedBy().getUuid()); + } + if (entity.getVoidReason() != null && !entity.getVoidReason().trim().isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_VOID_REASON, entity.getVoidReason()); + } + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java index 164dda4f..3cd14b08 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java @@ -22,6 +22,8 @@ final class BillingQueryStoreConstants { static final String RESOURCE_TYPE_BILL_DISCOUNT = "billing_bill_discount"; + static final String RESOURCE_TYPE_TIMESHEET = "billing_timesheet"; + static final String FIELD_RECEIPT_NUMBER = "receipt_number"; static final String FIELD_BILL_UUID = "bill_uuid"; @@ -104,6 +106,50 @@ final class BillingQueryStoreConstants { static final String FIELD_JUSTIFICATION = "justification"; + // Distinct sorted non-voided BillLineItemStatus values across the bill's line items. Lets + // workflow queries like "find bills with a REFUND_REQUESTED line item" succeed without + // scanning every line item row. + static final String FIELD_LINE_ITEM_STATUSES = "line_item_statuses"; + + // Distinct sorted Order.uuid values across the bill's non-voided line items. Lets clinical-to- + // billing trace queries like "find the bill for this lab order" succeed. + static final String FIELD_ORDER_UUIDS = "order_uuids"; + + // Provider.getName() of the cashier — denormalized alongside cashier_uuid so admin queries + // like "find bills cashier-handled by Mary" don't require an extra Provider lookup. + static final String FIELD_CASHIER_NAME = "cashier_name"; + + // Parallel to FIELD_PAYMENT_MODES (same TreeMap iteration order). Each entry is the total + // non-voided amount tendered for the mode at the same index. Lets "total Cash collected this + // week" succeed by zipping the two arrays at query time. + static final String FIELD_PAYMENT_MODE_AMOUNTS = "payment_mode_amounts"; + + // OpenMRS BaseOpenmrsData audit fields. `voided` and the (LocalDate) `date` are already on + // the document; these add the time-precise create/change timestamps, the change/void actors, + // and the void reason so audit queries like "bills modified in the last hour" or "bills + // voided by Alice" can succeed. + static final String FIELD_CREATED_AT = "created_at"; + + static final String FIELD_DATE_CHANGED = "date_changed"; + + static final String FIELD_DATE_VOIDED = "date_voided"; + + static final String FIELD_CREATOR_UUID = "creator_uuid"; + + static final String FIELD_CHANGED_BY_UUID = "changed_by_uuid"; + + static final String FIELD_VOIDED_BY_UUID = "voided_by_uuid"; + + static final String FIELD_VOID_REASON = "void_reason"; + + // Timesheet fields. clock_in / clock_out let "who was on duty at 2pm" succeed without + // scanning every timesheet row; the Provider/CashPoint UUIDs let the query narrow further. + static final String FIELD_CLOCK_IN = "clock_in"; + + static final String FIELD_CLOCK_OUT = "clock_out"; + + static final String FIELD_PROVIDER_UUID = "provider_uuid"; + private BillingQueryStoreConstants() { } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java new file mode 100644 index 00000000..8e01808f --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java @@ -0,0 +1,52 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.model.Timesheet; +import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice; + +public class TimesheetIndexingAdvice extends AbstractIndexingAdvice { + + // ITimesheetService exposes the generic IEntityDataService surface: save, purge, voidEntity, + // unvoidEntity. AOP only intercepts outgoing calls, so voidEntity's internal save() does NOT + // fire the save trigger (self-call) — we must list voidEntity/unvoidEntity explicitly. The + // service's domain method closeOpenTimesheets internally calls save on each open row, but + // because the close goes through the proxy boundary back into save on the same proxy, the + // per-row save fires the advice on its own; closeOpenTimesheets is therefore omitted. + static final Set TRIGGER_METHODS = new HashSet<>(Arrays.asList("save", "voidEntity", "unvoidEntity", "purge")); + + static final Set PURGE_METHODS = Collections.singleton("purge"); + + @Override + protected Class getSupportedType() { + return Timesheet.class; + } + + @Override + protected TimesheetRecordSerializer serializer() { + return Context.getRegisteredComponent("billing.querystore.serializer.timesheet", TimesheetRecordSerializer.class); + } + + @Override + protected Set triggerMethods() { + return TRIGGER_METHODS; + } + + @Override + protected Set purgeMethods() { + return PURGE_METHODS; + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java new file mode 100644 index 00000000..5cb2b4ad --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java @@ -0,0 +1,91 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.time.LocalDate; +import java.util.Date; + +import org.openmrs.Provider; +import org.openmrs.module.billing.api.model.CashPoint; +import org.openmrs.module.billing.api.model.Timesheet; +import org.openmrs.module.querystore.model.QueryDocument; +import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; +import org.openmrs.module.querystore.util.DateFormatUtil; + +public class TimesheetRecordSerializer extends AbstractRecordSerializer { + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_TIMESHEET; + } + + @Override + public Class getSupportedType() { + return Timesheet.class; + } + + @Override + protected String getPatientUuid(Timesheet timesheet) { + // Timesheets are provider-scoped, not patient-scoped — return null. AbstractRecordSerializer + // allows null patientUuid for administrative documents; the document is still indexed under + // the resource type and queryable via provider_uuid. + return null; + } + + @Override + protected String getResourceUuid(Timesheet timesheet) { + return timesheet.getUuid(); + } + + @Override + protected LocalDate getDate(Timesheet timesheet) { + // Use clockIn when available — that's the natural calendar key for "who was on duty on + // 2026-05-20". Fall back to dateCreated for in-progress rows that haven't clocked in yet + // (rare but possible for half-constructed records). + Date anchor = timesheet.getClockIn() != null ? timesheet.getClockIn() : timesheet.getDateCreated(); + return DateFormatUtil.toLocalDate(anchor); + } + + @Override + protected void populate(Timesheet timesheet, QueryDocument doc) { + Provider cashier = timesheet.getCashier(); + CashPoint cashPoint = timesheet.getCashPoint(); + String cashierName = cashier != null && cashier.getName() != null ? cashier.getName() : ""; + String cashPointName = cashPoint != null && cashPoint.getName() != null ? cashPoint.getName() : ""; + + doc.setText(String.format("Timesheet for %s at %s. Clock in: %s. Clock out: %s.", + cashierName.isEmpty() ? timesheet.getUuid() : cashierName, + cashPointName.isEmpty() ? "unspecified" : cashPointName, + timesheet.getClockIn() != null ? timesheet.getClockIn().toString() : "—", + timesheet.getClockOut() != null ? timesheet.getClockOut().toString() : "open")); + + if (cashier != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_PROVIDER_UUID, cashier.getUuid()); + if (cashier.getName() != null && !cashier.getName().trim().isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_NAME, cashier.getName()); + } + } + if (cashPoint != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID, cashPoint.getUuid()); + if (cashPoint.getName() != null && !cashPoint.getName().trim().isEmpty()) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME, cashPoint.getName()); + } + } + if (timesheet.getClockIn() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CLOCK_IN, timesheet.getClockIn()); + } + if (timesheet.getClockOut() != null) { + doc.putMetadata(BillingQueryStoreConstants.FIELD_CLOCK_OUT, timesheet.getClockOut()); + } + doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, timesheet.getVoided()); + + BillingAuditFields.populate(doc, timesheet); + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java new file mode 100644 index 00000000..55edf803 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import org.openmrs.module.querystore.bootstrap.TypeBootstrapper; +import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer; +import org.openmrs.module.querystore.spi.ResourceTypeProvider; + +public class TimesheetResourceTypeProvider implements ResourceTypeProvider { + + private final TimesheetRecordSerializer serializer; + + public TimesheetResourceTypeProvider(TimesheetRecordSerializer serializer) { + this.serializer = serializer; + } + + @Override + public String getResourceType() { + return BillingQueryStoreConstants.RESOURCE_TYPE_TIMESHEET; + } + + @Override + public ClinicalRecordSerializer getSerializer() { + return serializer; + } + + @Override + public TypeBootstrapper getBootstrapper() { + return null; + } +} diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index a43d62cf..7751053c 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -359,6 +359,10 @@ class="org.openmrs.module.billing.api.querystore.BillDiscountRecordSerializer" lazy-init="true"/> + + @@ -377,6 +381,12 @@ + + + + diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java index 1b7692a3..9a3fc375 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java @@ -28,6 +28,9 @@ import org.openmrs.module.billing.api.model.CashPoint; import org.openmrs.module.billing.api.model.DiscountStatus; import org.openmrs.module.billing.api.model.DiscountType; +import org.openmrs.Order; +import org.openmrs.User; +import org.openmrs.module.billing.api.model.BillLineItemStatus; import org.openmrs.module.billing.api.model.Payment; import org.openmrs.module.billing.api.model.PaymentMode; import org.openmrs.module.querystore.model.QueryDocument; @@ -272,6 +275,185 @@ public void serialize_shouldHandleNullLineItemsCollection() { assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES)); } + @Test + public void serialize_shouldEmitPaymentModeAmountsInParallelToModes() { + // payment_modes and payment_mode_amounts must be parallel — payment_modes[i] paid amount + // payment_mode_amounts[i]. Two payments of the same mode are summed into one entry. + Bill bill = postedBillWithLineItem("R-300", new BigDecimal("100"), 1); + addPayment(bill, new BigDecimal("30.00"), paymentMode("Mobile Money")); + addPayment(bill, new BigDecimal("40.00"), paymentMode("Cash")); + addPayment(bill, new BigDecimal("20.00"), paymentMode("Mobile Money")); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("Cash", "Mobile Money"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES)); + assertEquals(Arrays.asList("40.00", "50.00"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODE_AMOUNTS)); + } + + @Test + public void serialize_shouldOmitPaymentModeAmountsWhenNoPayments() { + Bill bill = postedBillWithLineItem("R-301", new BigDecimal("100"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_PAYMENT_MODE_AMOUNTS)); + } + + @Test + public void serialize_shouldEmitDistinctSortedLineItemStatuses() { + // "Find bills with a REFUND_REQUESTED line item" requires the workflow status surfaced; + // the BillStatus field is bill-level only. Voided line items are excluded so a once- + // requested-then-voided line doesn't leave a phantom REFUND_REQUESTED in the index. + Bill bill = postedBillWithLineItem("R-302", new BigDecimal("50"), 1); + bill.getLineItems().get(0).setStatus(BillLineItemStatus.PAID); + BillLineItem second = newLineItem(new BigDecimal("20"), 1); + second.setStatus(BillLineItemStatus.REFUND_REQUESTED); + bill.getLineItems().add(second); + BillLineItem voided = newLineItem(new BigDecimal("10"), 1); + voided.setStatus(BillLineItemStatus.REFUNDED); + voided.setVoided(true); + bill.getLineItems().add(voided); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("PAID", "REFUND_REQUESTED"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_STATUSES)); + } + + @Test + public void serialize_shouldOmitLineItemStatusesWhenAllNullOrVoided() { + Bill bill = postedBillWithLineItem("R-303", new BigDecimal("50"), 1); + // line item has no status set (null) + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_STATUSES)); + } + + @Test + public void serialize_shouldEmitDistinctSortedOrderUuids() { + // Clinical-to-billing trace: "find the bill for this lab order" matches against + // order_uuids. Voided line items contribute nothing — the index reflects the bill's + // current effective ordered work, not its history. + Bill bill = postedBillWithLineItem("R-304", new BigDecimal("50"), 1); + Order orderA = new Order(); + orderA.setUuid("z-order"); + Order orderB = new Order(); + orderB.setUuid("a-order"); + bill.getLineItems().get(0).setOrder(orderA); + BillLineItem second = newLineItem(new BigDecimal("20"), 1); + second.setOrder(orderB); + bill.getLineItems().add(second); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals(Arrays.asList("a-order", "z-order"), + doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ORDER_UUIDS)); + } + + @Test + public void serialize_shouldOmitOrderUuidsWhenNoLineItemHasOrder() { + Bill bill = postedBillWithLineItem("R-305", new BigDecimal("50"), 1); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ORDER_UUIDS)); + } + + @Test + public void serialize_shouldEmitCashierNameAlongsideUuid() { + // "Find bills cashier-handled by Mary" — denormalized name avoids a Provider lookup. + // Provider.getName() derives from the linked Person's PersonName when there's no metadata + // name; we build a Person+PersonName to match the production data shape. + Bill bill = postedBillWithLineItem("R-306", new BigDecimal("50"), 1); + bill.setCashier(providerWithPersonName("cashier-uuid", "Mary")); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals("cashier-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_UUID)); + assertEquals("Mary", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_NAME)); + } + + @Test + public void serialize_shouldOmitCashierNameWhenProviderHasNoName() { + // Production providers usually have a Person; system providers (REST-created with no + // Person and no metadata name) would otherwise emit an empty/whitespace cashier_name — + // the guard must skip the field entirely so a "find bills with a named cashier" + // exists-filter doesn't match these system rows. + Bill bill = postedBillWithLineItem("R-306b", new BigDecimal("50"), 1); + Provider cashier = new Provider(); + cashier.setUuid("cashier-no-name"); + bill.setCashier(cashier); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals("cashier-no-name", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASHIER_NAME)); + } + + @Test + public void serialize_shouldOmitAuditFieldsWhenAbsent() { + // The audit-fields helper must skip-when-null so a "voided by" exists-query doesn't + // false-positive on rows where voidedBy was never set. dateCreated is always present on + // a saved bill, but the others (changedBy, voidedBy, dateChanged, dateVoided, voidReason) + // are populated only when the bill is touched. + Bill bill = postedBillWithLineItem("R-307b", new BigDecimal("50"), 1); + bill.setCreator(null); + bill.setDateChanged(null); + bill.setChangedBy(null); + bill.setDateVoided(null); + bill.setVoidedBy(null); + bill.setVoidReason(null); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CREATOR_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_DATE_CHANGED)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CHANGED_BY_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_DATE_VOIDED)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_VOIDED_BY_UUID)); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_VOID_REASON)); + } + + @Test + public void serialize_shouldEmitAuditFieldsWhenPresent() { + // Audit context (creator, changedBy, voidedBy, voidReason, dateChanged, dateVoided, + // createdAt) is what makes "who voided this bill and when" queries possible. dateCreated + // is already on the parent QueryDocument as LocalDate; created_at is the Date timestamp + // for time-of-day filtering. + Bill bill = postedBillWithLineItem("R-307", new BigDecimal("50"), 1); + bill.setCreator(userWithUuid("creator-uuid")); + bill.setChangedBy(userWithUuid("changer-uuid")); + bill.setVoidedBy(userWithUuid("voider-uuid")); + bill.setVoidReason("erroneously created"); + Date changed = new Date(); + bill.setDateChanged(changed); + Date voided = new Date(); + bill.setDateVoided(voided); + + QueryDocument doc = serializer.serialize(bill); + + assertNotNull(doc); + assertEquals("creator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATOR_UUID)); + assertEquals("changer-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CHANGED_BY_UUID)); + assertEquals("voider-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VOIDED_BY_UUID)); + assertEquals("erroneously created", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VOID_REASON)); + assertEquals(changed, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_CHANGED)); + assertEquals(voided, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_VOIDED)); + assertNotNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATED_AT)); + } + @Test public void serialize_shouldEmitDistinctSortedPaymentModes() { // "Settlements by tender type" is an ops/finance query. The list must be distinct (two @@ -585,6 +767,24 @@ private static PaymentMode paymentMode(String name) { return mode; } + private static User userWithUuid(String uuid) { + User user = new User(); + user.setUuid(uuid); + return user; + } + + private static Provider providerWithPersonName(String uuid, String name) { + Provider provider = new Provider(); + provider.setUuid(uuid); + org.openmrs.Person person = new org.openmrs.Person(); + org.openmrs.PersonName personName = new org.openmrs.PersonName(); + personName.setGivenName(name); + personName.setFamilyName(""); + person.addName(personName); + provider.setPerson(person); + return provider; + } + private void addDiscount(Bill bill, DiscountStatus status, boolean voided) { if (bill.getDiscounts() == null) { bill.setDiscounts(new HashSet<>()); diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java index 41054a65..7d320104 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java @@ -16,6 +16,7 @@ import org.openmrs.module.billing.api.BillDiscountService; import org.openmrs.module.billing.api.BillRefundService; import org.openmrs.module.billing.api.BillService; +import org.openmrs.module.billing.api.ITimesheetService; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -60,6 +61,17 @@ public void billDiscountIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethod "BillDiscountIndexingAdvice"); } + @Test + public void timesheetIndexingAdvice_triggerMethodsShouldAllExistOnTimesheetService() { + assertAllMethodsExist(TimesheetIndexingAdvice.TRIGGER_METHODS, ITimesheetService.class, "TimesheetIndexingAdvice"); + } + + @Test + public void timesheetIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() { + assertSubset(TimesheetIndexingAdvice.PURGE_METHODS, TimesheetIndexingAdvice.TRIGGER_METHODS, + "TimesheetIndexingAdvice"); + } + private static void assertAllMethodsExist(Set methodNames, Class iface, String adviceName) { for (String name : methodNames) { assertTrue(hasMethod(iface, name), adviceName + " references method '" + name + "' but no such method exists on " diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java new file mode 100644 index 00000000..8863b217 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java @@ -0,0 +1,142 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.billing.api.querystore; + +import java.util.Date; + +import org.junit.jupiter.api.Test; +import org.openmrs.Provider; +import org.openmrs.User; +import org.openmrs.module.billing.api.model.CashPoint; +import org.openmrs.module.billing.api.model.Timesheet; +import org.openmrs.module.querystore.model.QueryDocument; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TimesheetRecordSerializerTest { + + private static final String TIMESHEET_UUID = "timesheet-uuid-1"; + + private final TimesheetRecordSerializer serializer = new TimesheetRecordSerializer(); + + @Test + public void serialize_shouldSetCoreFields() { + Timesheet timesheet = newTimesheet(new Date(), null); + + QueryDocument doc = serializer.serialize(timesheet); + + assertNotNull(doc); + assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_TIMESHEET, doc.getResourceType()); + assertEquals(TIMESHEET_UUID, doc.getResourceUuid()); + // Timesheet is provider-scoped, not patient-scoped — patientUuid must be null so the + // document doesn't accidentally get filed under any patient. + assertNull(doc.getPatientUuid()); + assertNotNull(doc.getDate()); + } + + @Test + public void serialize_shouldEmitProviderAndCashPointMetadata() { + // Provider.getName() derives from the linked Person's PersonName when there's no metadata + // name; build a Person+PersonName to match the production data shape. + Timesheet timesheet = newTimesheet(new Date(), null); + Provider cashier = new Provider(); + cashier.setUuid("provider-uuid"); + org.openmrs.Person person = new org.openmrs.Person(); + org.openmrs.PersonName personName = new org.openmrs.PersonName(); + personName.setGivenName("Mary"); + personName.setFamilyName(""); + person.addName(personName); + cashier.setPerson(person); + timesheet.setCashier(cashier); + CashPoint cashPoint = new CashPoint(); + cashPoint.setUuid("cashpoint-uuid"); + cashPoint.setName("Main Counter"); + timesheet.setCashPoint(cashPoint); + + QueryDocument doc = serializer.serialize(timesheet); + + assertNotNull(doc); + assertEquals("provider-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PROVIDER_UUID)); + assertEquals("Mary", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_NAME)); + assertEquals("cashpoint-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID)); + assertEquals("Main Counter", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME)); + } + + @Test + public void serialize_shouldEmitClockInAndClockOut() { + Date clockIn = new Date(1000L); + Date clockOut = new Date(60000L); + Timesheet timesheet = newTimesheet(clockIn, clockOut); + + QueryDocument doc = serializer.serialize(timesheet); + + assertNotNull(doc); + assertEquals(clockIn, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CLOCK_IN)); + assertEquals(clockOut, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CLOCK_OUT)); + } + + @Test + public void serialize_shouldOmitClockOutForOpenTimesheet() { + // "Who is on duty right now?" — open timesheets have clockOut=null. The field must be + // absent (not stored as null) so an exists-filter on clock_out cleanly separates closed + // timesheets from open ones. + Timesheet timesheet = newTimesheet(new Date(), null); + + QueryDocument doc = serializer.serialize(timesheet); + + assertNotNull(doc); + assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CLOCK_OUT)); + assertTrue(doc.getText().contains("Clock out: open"), doc.getText()); + } + + @Test + public void serialize_shouldEmitVoidedFlag() { + Timesheet timesheet = newTimesheet(new Date(), new Date()); + timesheet.setVoided(true); + + QueryDocument doc = serializer.serialize(timesheet); + + assertNotNull(doc); + assertEquals(Boolean.TRUE, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VOIDED)); + } + + @Test + public void serialize_shouldEmitAuditFieldsWhenPresent() { + // Same shared audit-fields contract as Bill / BillRefund / BillDiscount: "who clocked + // this in" and "who voided this row" must be answerable through the index. + Timesheet timesheet = newTimesheet(new Date(), new Date()); + User creator = new User(); + creator.setUuid("creator-uuid"); + timesheet.setCreator(creator); + Date changed = new Date(); + timesheet.setDateChanged(changed); + + QueryDocument doc = serializer.serialize(timesheet); + + assertNotNull(doc); + assertEquals("creator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATOR_UUID)); + assertEquals(changed, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_CHANGED)); + assertNotNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATED_AT)); + } + + private Timesheet newTimesheet(Date clockIn, Date clockOut) { + Timesheet timesheet = new Timesheet(); + timesheet.setUuid(TIMESHEET_UUID); + timesheet.setClockIn(clockIn); + timesheet.setClockOut(clockOut); + timesheet.setVoided(false); + timesheet.setDateCreated(new Date()); + return timesheet; + } +} diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index 5c80563c..ff01b06f 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -58,6 +58,11 @@ org.openmrs.module.billing.api.querystore.BillDiscountIndexingAdvice + + org.openmrs.module.billing.api.ITimesheetService + org.openmrs.module.billing.api.querystore.TimesheetIndexingAdvice + + org.openmrs.module.billing.BillingModuleActivator From f764994c22698fa9dccd1fff1cfdf58f3834d976 Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Wed, 20 May 2026 18:45:42 +0300 Subject: [PATCH 8/9] Bump build target to Java 11 for querystore-api compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The querystore-api jar (now a require_module per omod/config.xml) is compiled with Java 11 bytecode (class version 55). A Java 8 build couldn't load it at runtime regardless of source level, so keeping maven.compiler.source/target=8 only created the illusion of broader compatibility. The CI matrix drops the Java 8 cell for the same reason — it would only ever surface a compatibility error that doesn't apply to any deployment that can actually use the querystore slice. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 6 ++++++ pom.xml | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f84440e6..0ffdf883 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,12 @@ concurrency: jobs: build: uses: openmrs/openmrs-contrib-gha-workflows/.github/workflows/build-backend-module.yml@main + # Java 8 is excluded because the querystore-api dependency is compiled with Java 11 + # bytecode (class version 55). A Java 8 deployment cannot load it at runtime, so the + # Java 8 matrix cell would only ever surface a compatibility error that doesn't apply + # to any deployment that can actually use this slice. + with: + java_versions: '[11, 17, 21]' permissions: contents: read id-token: write diff --git a/pom.xml b/pom.xml index 8b9bb9b6..e7ef88cb 100644 --- a/pom.xml +++ b/pom.xml @@ -50,8 +50,11 @@ 2.7.8 - 8 - 8 + + 11 + 11 8.0.2 1.4.0 2.4.0 From c7a334773144c8305a6d753a5f787fb2e301f22b Mon Sep 17 00:00:00 2001 From: dkayiwa Date: Thu, 21 May 2026 18:25:53 +0300 Subject: [PATCH 9/9] Drop timesheet indexing from the querystore slice The broader timesheet feature is being removed in PR #164 (cashier HR concern, not a billing concern), so indexing timesheets into the querystore would ship a resource type whose source data is about to disappear. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/querystore/BillingAuditFields.java | 4 +- .../BillingQueryStoreConstants.java | 10 -- .../querystore/TimesheetIndexingAdvice.java | 52 ------- .../querystore/TimesheetRecordSerializer.java | 91 ----------- .../TimesheetResourceTypeProvider.java | 38 ----- .../resources/moduleApplicationContext.xml | 10 -- .../querystore/IndexingAdviceConfigTest.java | 12 -- .../TimesheetRecordSerializerTest.java | 142 ------------------ omod/src/main/resources/config.xml | 5 - 9 files changed, 2 insertions(+), 362 deletions(-) delete mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java delete mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java delete mode 100644 api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java delete mode 100644 api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java index 98c38c7b..83106ddf 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java @@ -12,8 +12,8 @@ import org.openmrs.BaseOpenmrsData; import org.openmrs.module.querystore.model.QueryDocument; -// OpenMRS BaseOpenmrsData audit columns shared by Bill / BillRefund / BillDiscount / Timesheet. -// Centralised because all four resource types want the same six audit fields (creator, changedBy, +// OpenMRS BaseOpenmrsData audit columns shared by Bill / BillRefund / BillDiscount. +// Centralised because all three resource types want the same six audit fields (creator, changedBy, // dateChanged, voidedBy, dateVoided, voidReason) plus the time-precise createdAt; emitting them // inline would invite drift — a future refactor adds the field to one serializer and forgets the // others, and audit queries silently miss the new resource type. diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java index 3cd14b08..30d9b185 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java @@ -22,8 +22,6 @@ final class BillingQueryStoreConstants { static final String RESOURCE_TYPE_BILL_DISCOUNT = "billing_bill_discount"; - static final String RESOURCE_TYPE_TIMESHEET = "billing_timesheet"; - static final String FIELD_RECEIPT_NUMBER = "receipt_number"; static final String FIELD_BILL_UUID = "bill_uuid"; @@ -142,14 +140,6 @@ final class BillingQueryStoreConstants { static final String FIELD_VOID_REASON = "void_reason"; - // Timesheet fields. clock_in / clock_out let "who was on duty at 2pm" succeed without - // scanning every timesheet row; the Provider/CashPoint UUIDs let the query narrow further. - static final String FIELD_CLOCK_IN = "clock_in"; - - static final String FIELD_CLOCK_OUT = "clock_out"; - - static final String FIELD_PROVIDER_UUID = "provider_uuid"; - private BillingQueryStoreConstants() { } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java deleted file mode 100644 index 8e01808f..00000000 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetIndexingAdvice.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public License, - * v. 2.0. If a copy of the MPL was not distributed with this file, You can - * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under - * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. - * - * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS - * graphic logo is a trademark of OpenMRS Inc. - */ -package org.openmrs.module.billing.api.querystore; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -import org.openmrs.api.context.Context; -import org.openmrs.module.billing.api.model.Timesheet; -import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice; - -public class TimesheetIndexingAdvice extends AbstractIndexingAdvice { - - // ITimesheetService exposes the generic IEntityDataService surface: save, purge, voidEntity, - // unvoidEntity. AOP only intercepts outgoing calls, so voidEntity's internal save() does NOT - // fire the save trigger (self-call) — we must list voidEntity/unvoidEntity explicitly. The - // service's domain method closeOpenTimesheets internally calls save on each open row, but - // because the close goes through the proxy boundary back into save on the same proxy, the - // per-row save fires the advice on its own; closeOpenTimesheets is therefore omitted. - static final Set TRIGGER_METHODS = new HashSet<>(Arrays.asList("save", "voidEntity", "unvoidEntity", "purge")); - - static final Set PURGE_METHODS = Collections.singleton("purge"); - - @Override - protected Class getSupportedType() { - return Timesheet.class; - } - - @Override - protected TimesheetRecordSerializer serializer() { - return Context.getRegisteredComponent("billing.querystore.serializer.timesheet", TimesheetRecordSerializer.class); - } - - @Override - protected Set triggerMethods() { - return TRIGGER_METHODS; - } - - @Override - protected Set purgeMethods() { - return PURGE_METHODS; - } -} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java deleted file mode 100644 index 5cb2b4ad..00000000 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializer.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public License, - * v. 2.0. If a copy of the MPL was not distributed with this file, You can - * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under - * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. - * - * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS - * graphic logo is a trademark of OpenMRS Inc. - */ -package org.openmrs.module.billing.api.querystore; - -import java.time.LocalDate; -import java.util.Date; - -import org.openmrs.Provider; -import org.openmrs.module.billing.api.model.CashPoint; -import org.openmrs.module.billing.api.model.Timesheet; -import org.openmrs.module.querystore.model.QueryDocument; -import org.openmrs.module.querystore.serialization.AbstractRecordSerializer; -import org.openmrs.module.querystore.util.DateFormatUtil; - -public class TimesheetRecordSerializer extends AbstractRecordSerializer { - - @Override - public String getResourceType() { - return BillingQueryStoreConstants.RESOURCE_TYPE_TIMESHEET; - } - - @Override - public Class getSupportedType() { - return Timesheet.class; - } - - @Override - protected String getPatientUuid(Timesheet timesheet) { - // Timesheets are provider-scoped, not patient-scoped — return null. AbstractRecordSerializer - // allows null patientUuid for administrative documents; the document is still indexed under - // the resource type and queryable via provider_uuid. - return null; - } - - @Override - protected String getResourceUuid(Timesheet timesheet) { - return timesheet.getUuid(); - } - - @Override - protected LocalDate getDate(Timesheet timesheet) { - // Use clockIn when available — that's the natural calendar key for "who was on duty on - // 2026-05-20". Fall back to dateCreated for in-progress rows that haven't clocked in yet - // (rare but possible for half-constructed records). - Date anchor = timesheet.getClockIn() != null ? timesheet.getClockIn() : timesheet.getDateCreated(); - return DateFormatUtil.toLocalDate(anchor); - } - - @Override - protected void populate(Timesheet timesheet, QueryDocument doc) { - Provider cashier = timesheet.getCashier(); - CashPoint cashPoint = timesheet.getCashPoint(); - String cashierName = cashier != null && cashier.getName() != null ? cashier.getName() : ""; - String cashPointName = cashPoint != null && cashPoint.getName() != null ? cashPoint.getName() : ""; - - doc.setText(String.format("Timesheet for %s at %s. Clock in: %s. Clock out: %s.", - cashierName.isEmpty() ? timesheet.getUuid() : cashierName, - cashPointName.isEmpty() ? "unspecified" : cashPointName, - timesheet.getClockIn() != null ? timesheet.getClockIn().toString() : "—", - timesheet.getClockOut() != null ? timesheet.getClockOut().toString() : "open")); - - if (cashier != null) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_PROVIDER_UUID, cashier.getUuid()); - if (cashier.getName() != null && !cashier.getName().trim().isEmpty()) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_NAME, cashier.getName()); - } - } - if (cashPoint != null) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID, cashPoint.getUuid()); - if (cashPoint.getName() != null && !cashPoint.getName().trim().isEmpty()) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME, cashPoint.getName()); - } - } - if (timesheet.getClockIn() != null) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_CLOCK_IN, timesheet.getClockIn()); - } - if (timesheet.getClockOut() != null) { - doc.putMetadata(BillingQueryStoreConstants.FIELD_CLOCK_OUT, timesheet.getClockOut()); - } - doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, timesheet.getVoided()); - - BillingAuditFields.populate(doc, timesheet); - } -} diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java deleted file mode 100644 index 55edf803..00000000 --- a/api/src/main/java/org/openmrs/module/billing/api/querystore/TimesheetResourceTypeProvider.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public License, - * v. 2.0. If a copy of the MPL was not distributed with this file, You can - * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under - * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. - * - * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS - * graphic logo is a trademark of OpenMRS Inc. - */ -package org.openmrs.module.billing.api.querystore; - -import org.openmrs.module.querystore.bootstrap.TypeBootstrapper; -import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer; -import org.openmrs.module.querystore.spi.ResourceTypeProvider; - -public class TimesheetResourceTypeProvider implements ResourceTypeProvider { - - private final TimesheetRecordSerializer serializer; - - public TimesheetResourceTypeProvider(TimesheetRecordSerializer serializer) { - this.serializer = serializer; - } - - @Override - public String getResourceType() { - return BillingQueryStoreConstants.RESOURCE_TYPE_TIMESHEET; - } - - @Override - public ClinicalRecordSerializer getSerializer() { - return serializer; - } - - @Override - public TypeBootstrapper getBootstrapper() { - return null; - } -} diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index 7751053c..a43d62cf 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -359,10 +359,6 @@ class="org.openmrs.module.billing.api.querystore.BillDiscountRecordSerializer" lazy-init="true"/> - - @@ -381,12 +377,6 @@ - - - - diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java index 7d320104..41054a65 100644 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java @@ -16,7 +16,6 @@ import org.openmrs.module.billing.api.BillDiscountService; import org.openmrs.module.billing.api.BillRefundService; import org.openmrs.module.billing.api.BillService; -import org.openmrs.module.billing.api.ITimesheetService; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -61,17 +60,6 @@ public void billDiscountIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethod "BillDiscountIndexingAdvice"); } - @Test - public void timesheetIndexingAdvice_triggerMethodsShouldAllExistOnTimesheetService() { - assertAllMethodsExist(TimesheetIndexingAdvice.TRIGGER_METHODS, ITimesheetService.class, "TimesheetIndexingAdvice"); - } - - @Test - public void timesheetIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() { - assertSubset(TimesheetIndexingAdvice.PURGE_METHODS, TimesheetIndexingAdvice.TRIGGER_METHODS, - "TimesheetIndexingAdvice"); - } - private static void assertAllMethodsExist(Set methodNames, Class iface, String adviceName) { for (String name : methodNames) { assertTrue(hasMethod(iface, name), adviceName + " references method '" + name + "' but no such method exists on " diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java deleted file mode 100644 index 8863b217..00000000 --- a/api/src/test/java/org/openmrs/module/billing/api/querystore/TimesheetRecordSerializerTest.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public License, - * v. 2.0. If a copy of the MPL was not distributed with this file, You can - * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under - * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. - * - * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS - * graphic logo is a trademark of OpenMRS Inc. - */ -package org.openmrs.module.billing.api.querystore; - -import java.util.Date; - -import org.junit.jupiter.api.Test; -import org.openmrs.Provider; -import org.openmrs.User; -import org.openmrs.module.billing.api.model.CashPoint; -import org.openmrs.module.billing.api.model.Timesheet; -import org.openmrs.module.querystore.model.QueryDocument; - -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.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class TimesheetRecordSerializerTest { - - private static final String TIMESHEET_UUID = "timesheet-uuid-1"; - - private final TimesheetRecordSerializer serializer = new TimesheetRecordSerializer(); - - @Test - public void serialize_shouldSetCoreFields() { - Timesheet timesheet = newTimesheet(new Date(), null); - - QueryDocument doc = serializer.serialize(timesheet); - - assertNotNull(doc); - assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_TIMESHEET, doc.getResourceType()); - assertEquals(TIMESHEET_UUID, doc.getResourceUuid()); - // Timesheet is provider-scoped, not patient-scoped — patientUuid must be null so the - // document doesn't accidentally get filed under any patient. - assertNull(doc.getPatientUuid()); - assertNotNull(doc.getDate()); - } - - @Test - public void serialize_shouldEmitProviderAndCashPointMetadata() { - // Provider.getName() derives from the linked Person's PersonName when there's no metadata - // name; build a Person+PersonName to match the production data shape. - Timesheet timesheet = newTimesheet(new Date(), null); - Provider cashier = new Provider(); - cashier.setUuid("provider-uuid"); - org.openmrs.Person person = new org.openmrs.Person(); - org.openmrs.PersonName personName = new org.openmrs.PersonName(); - personName.setGivenName("Mary"); - personName.setFamilyName(""); - person.addName(personName); - cashier.setPerson(person); - timesheet.setCashier(cashier); - CashPoint cashPoint = new CashPoint(); - cashPoint.setUuid("cashpoint-uuid"); - cashPoint.setName("Main Counter"); - timesheet.setCashPoint(cashPoint); - - QueryDocument doc = serializer.serialize(timesheet); - - assertNotNull(doc); - assertEquals("provider-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PROVIDER_UUID)); - assertEquals("Mary", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_NAME)); - assertEquals("cashpoint-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID)); - assertEquals("Main Counter", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME)); - } - - @Test - public void serialize_shouldEmitClockInAndClockOut() { - Date clockIn = new Date(1000L); - Date clockOut = new Date(60000L); - Timesheet timesheet = newTimesheet(clockIn, clockOut); - - QueryDocument doc = serializer.serialize(timesheet); - - assertNotNull(doc); - assertEquals(clockIn, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CLOCK_IN)); - assertEquals(clockOut, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CLOCK_OUT)); - } - - @Test - public void serialize_shouldOmitClockOutForOpenTimesheet() { - // "Who is on duty right now?" — open timesheets have clockOut=null. The field must be - // absent (not stored as null) so an exists-filter on clock_out cleanly separates closed - // timesheets from open ones. - Timesheet timesheet = newTimesheet(new Date(), null); - - QueryDocument doc = serializer.serialize(timesheet); - - assertNotNull(doc); - assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CLOCK_OUT)); - assertTrue(doc.getText().contains("Clock out: open"), doc.getText()); - } - - @Test - public void serialize_shouldEmitVoidedFlag() { - Timesheet timesheet = newTimesheet(new Date(), new Date()); - timesheet.setVoided(true); - - QueryDocument doc = serializer.serialize(timesheet); - - assertNotNull(doc); - assertEquals(Boolean.TRUE, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VOIDED)); - } - - @Test - public void serialize_shouldEmitAuditFieldsWhenPresent() { - // Same shared audit-fields contract as Bill / BillRefund / BillDiscount: "who clocked - // this in" and "who voided this row" must be answerable through the index. - Timesheet timesheet = newTimesheet(new Date(), new Date()); - User creator = new User(); - creator.setUuid("creator-uuid"); - timesheet.setCreator(creator); - Date changed = new Date(); - timesheet.setDateChanged(changed); - - QueryDocument doc = serializer.serialize(timesheet); - - assertNotNull(doc); - assertEquals("creator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATOR_UUID)); - assertEquals(changed, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_CHANGED)); - assertNotNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATED_AT)); - } - - private Timesheet newTimesheet(Date clockIn, Date clockOut) { - Timesheet timesheet = new Timesheet(); - timesheet.setUuid(TIMESHEET_UUID); - timesheet.setClockIn(clockIn); - timesheet.setClockOut(clockOut); - timesheet.setVoided(false); - timesheet.setDateCreated(new Date()); - return timesheet; - } -} diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index ff01b06f..5c80563c 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -58,11 +58,6 @@ org.openmrs.module.billing.api.querystore.BillDiscountIndexingAdvice - - org.openmrs.module.billing.api.ITimesheetService - org.openmrs.module.billing.api.querystore.TimesheetIndexingAdvice - - org.openmrs.module.billing.BillingModuleActivator