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/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..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 @@ -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,32 @@ 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) { + // 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; + } + 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/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/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..ea21e209 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java @@ -0,0 +1,103 @@ +/* + * 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()); + } + + BillingAuditFields.populate(doc, discount); + } +} 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/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..bfbaba2c --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java @@ -0,0 +1,264 @@ +/* + * 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 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; +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); + + // 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(); + 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(), 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); + 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.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()); + 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()); + } + + 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()); + } + 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())); + + BillingAuditFields.populate(doc, bill); + } + + 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; + } + + // 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 totals; + } + for (Payment payment : bill.getPayments()) { + if (payment == null || payment.getVoided()) { + continue; + } + PaymentMode mode = payment.getInstanceType(); + 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 totals; + } + + 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 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 + // 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/BillRefundIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java new file mode 100644 index 00000000..c90a47f3 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java @@ -0,0 +1,49 @@ +/* + * 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.BillRefund; +import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice; + +public class BillRefundIndexingAdvice extends AbstractIndexingAdvice { + + // 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.emptySet(); + + @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..381e81da --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java @@ -0,0 +1,114 @@ +/* + * 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.Collections; + +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; + } + // 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(); + // 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() : "", itemClause)); + + 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 (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()); + } + 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()); + } + + BillingAuditFields.populate(doc, refund); + } +} 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/BillingAuditFields.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java new file mode 100644 index 00000000..83106ddf --- /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. +// 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. +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/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 new file mode 100644 index 00000000..30d9b185 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java @@ -0,0 +1,145 @@ +/* + * 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 RESOURCE_TYPE_BILL_DISCOUNT = "billing_bill_discount"; + + 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"; + + // 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"; + + 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"; + + // 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"; + + private BillingQueryStoreConstants() { + } +} diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index f309b94d..a43d62cf 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -337,6 +337,46 @@ + + + + + + + + + + + + + + + + + + + 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/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/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 new file mode 100644 index 00000000..9a3fc375 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java @@ -0,0 +1,799 @@ +/* + * 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.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.List; + +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.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.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; +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 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_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_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 + // 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 + // 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<>()); + 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); + 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) { + 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) { + addPayment(bill, amount, null); + } + + private void addPayment(Bill bill, BigDecimal amount, PaymentMode mode) { + if (bill.getPayments() == null) { + bill.setPayments(new HashSet<>()); + } + 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 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<>()); + } + 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/BillRefundRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java new file mode 100644 index 00000000..bda94a98 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java @@ -0,0 +1,256 @@ +/* + * 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.Collections; +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.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 { + + 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_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"); + + 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; + } + + 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; + } +} 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..41054a65 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java @@ -0,0 +1,85 @@ +/* + * 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.BillDiscountService; +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"); + } + + @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 " + + 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; + } +} 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 diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index e40c0adb..5c80563c 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -40,8 +40,24 @@ 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.api.BillDiscountService + org.openmrs.module.billing.api.querystore.BillDiscountIndexingAdvice + + org.openmrs.module.billing.BillingModuleActivator diff --git a/pom.xml b/pom.xml index 8d0c85f4..e7ef88cb 100644 --- a/pom.xml +++ b/pom.xml @@ -50,12 +50,16 @@ 2.7.8 - 8 - 8 + + 11 + 11 8.0.2 1.4.0 2.4.0 4.0.0 + 1.0.0-SNAPSHOT 1.18.38 1.17.6 @@ -154,6 +158,13 @@ provided + + org.openmrs.module + querystore-api + ${querystoreVersion} + provided + + org.reflections reflections @@ -391,6 +402,9 @@ openmrs-repo OpenMRS Public https://mavenrepo.openmrs.org/public + + true +