Index billing records in the QueryStore for chart search (optional 2.9 integration) - #186
Index billing records in the QueryStore for chart search (optional 2.9 integration)#186dkayiwa wants to merge 9 commits into
Conversation
Contributes bills, discounts and refunds to the OpenMRS QueryStore via its ResourceTypeProvider SPI so a clinician-facing semantic/keyword chart search (e.g. chartsearchai) can retrieve them. Three patient-scoped resource types are added: billing_bill (folding in line items, totals, payment status and balance), billing_discount and billing_refund. Steady-state indexing rides on core #6084's *ServiceEvent advice, which already fires for billing's save*/void*/purge* service methods, so no change is needed to the base billing services. Backfill uses HibernateTypeBootstrapper subclasses. Because QueryStore and the #6084 event API require the (unreleased) platform 2.9 plus an Elasticsearch/Lucene backend, this ships as a separate, optional billing-querystore omod built behind the 'querystore' Maven profile. The default build and the base billing.omod are unchanged and still target 2.7.8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #186 +/- ##
=========================================
Coverage 34.45% 34.45%
Complexity 809 809
=========================================
Files 213 213
Lines 5274 5274
Branches 646 646
=========================================
Hits 1817 1817
Misses 3252 3252
Partials 205 205 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Structural (Phase 1): - billing_bill no longer folds the discount-adjusted total/balance. Approving a fee waiver goes through saveBillDiscount, which does not re-save the bill, so a denormalized after-discount figure would silently go stale; expose the raw line-item total + amount_paid + status (all refreshed on every saveBill) instead, and leave discount detail to billing_discount. - Add stockmanagement to require_modules: the bill serializer references StockItem/Drug, so it must be visible to this module's classloader. Polish (Phase 2): - Extract AbstractBillChildRecordSerializer for the discount/refund serializers (shared patient-scope/uuid/date/bill-reference logic). - Add BillingQueryFields as the single source of truth for metadata key names. - Reuse AbstractRecordSerializer.trimToNull and QueryStoreConstants.FIELD_VISIT_UUID instead of re-implementing them; simplify getDate. - Split the unit-ambiguous discount_value into discount_percent (percentage only) plus an always-currency discount_amount; rename line_item_count to billed_service_count. - Skip patient-less records so the live path matches the backfill's patient filter. Tests: 8 serializer cases (added percentage-discount, empty-line-items, discount-independent total, patient-less skip). Build + package green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- S6213 (restricted identifier): rename the 'record' parameter to 'entity' in
AbstractBillChildRecordSerializer ('record' is a restricted identifier since Java 14).
- S135 (multiple continue): billedItemLabels now uses a single continue, nesting the
label handling instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uilds Found deploying to a live 2.9.0-SNAPSHOT standalone: billingquerystore failed to start with 'requires querystore 1.0, billing 2.4'. OpenMRS ranks 'X.Y.Z-SNAPSHOT' below a bare 'X.Y.Z', so the bare require_version 2.9.0 and require_module 1.0/2.4 were left unsatisfied by the running -SNAPSHOT builds. Filter the requirements from this module's properties (openmrsPlatformVersion / project.parent.version / querystoreVersion) so they match the exact versions built against, mirroring how chartsearchai requires querystore (1.0.0-SNAPSHOT). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✅ Verified end-to-end on a live OpenMRS 2.9 standaloneDeployed and tested against a Deploy. Built
1. Live projection via core #6084 — the PR's central assumption ✅Created a bill through the normal service path and did not reindex: 25 s later, a clinician chart search returned it: So 2. Backfill via the bootstrapper ✅
VerdictA clinician's chart search retrieves a patient's bills as Not covered: |
| * waiver is patient-relevant because it often signals a subsidized programme (HIV, TB, under-5, | ||
| * indigent) or financial hardship. It is indexed as its own type - rather than folded into the bill | ||
| * - because its lifecycle (PENDING -> APPROVED/REJECTED) is driven through its own | ||
| * {@code saveBillDiscount} service call, so its own save events keep the projection current without |
There was a problem hiding this comment.
This is the load-bearing assumption for both the discount and refund types, and I don't think it holds. saveBillDiscount / saveBillRefund never emit a *ServiceEvent, so nothing projects billing_discount / billing_refund on save.
Core #6084's advice (OpenmrsServiceEventAdvice) only intercepts save*/void*/purge* when the target is an OpenmrsService:
@Around("(execution(* *.save*(..)) || execution(* *.create*(..))) && target(org.openmrs.api.OpenmrsService)")BillServiceImpl extends BaseOpenmrsService implements BillService (and BillService extends OpenmrsService), which is exactly why saveBill fired and billing_bill indexed live in your run. But BillDiscountServiceImpl / BillRefundServiceImpl only implements BillDiscountService / BillRefundService, and neither interface extends OpenmrsService, so the pointcut never matches their save* methods. There is no billing-side publisher either, and the cascade doesn't rescue it: SerializerRegistry.resolve keys on entity type, so the SaveServiceEvent<Bill> that saveBillRefund's internal saveBill re-save produces only ever resolves to the bill serializer, never the refund one.
The effect is silent (no exception): a discount or refund saved through the service is never projected live, it only appears after an admin reindex. Status is worse: a discount approved (PENDING to APPROVED), or a refund completed, after the initial backfill keeps its stale status in the index until the next manual reindex, since the "live sync handles later status changes" assumption in BillDiscountBootstrapper never fires. That is also why these two were the paths your live verification didn't cover. billing_bill is genuinely fine.
So this blocks the discount/refund half of the feature as documented (this javadoc, the refund serializer's, the bootstrapper's, and the README all assert live indexing works). Two options, I'd pick the first:
- Put these two services on the same path
BillServicealready uses:BillDiscountService/BillRefundService extends OpenmrsService, with the implsextends BaseOpenmrsService. Identical wiring, so theisAopProxyguard behaves just likesaveBill; it's harmless on platforms without the advice or without querystore installed. It does mean a small base-module change, so the "only edits are the profile + README" framing stops being true. - Keep the base module untouched and add the small publisher aspect the README mentions, scoped to these two services, here in the submodule.
Quick way to confirm, same shape as your bill check: save a discount without reindexing and search for it. It should be missing (and after approval its status should lag) until this is addressed.
Deploying to a live 2.9 standalone showed billing_discount/billing_refund never live-projected (only backfill did): BillDiscountService/BillRefundService are plain interfaces, not OpenmrsServices, so core #6084's service-event AOP advice (target(OpenmrsService)) never fires for saveBillDiscount/saveBillRefund. billing_bill was unaffected because BillService extends OpenmrsService. Rather than change those base-billing services, consume core's non-AOP path: the Hibernate EventInterceptor publishes a SaveDbEvent/DeleteDbEvent for every entity change, in-transaction on the flush thread. BillChildDbEventListener listens for those, filters to BillDiscount/BillRefund (Bill still syncs via its SaveServiceEvent), and drives the same querystore projection pipeline (RecordProjector + the reachable querystore.sync.* beans) the service consumer uses. No base-billing change. Verified live on a 2.9 standalone: a newly created discount (50) and refund (75) - with no reindex - are returned by a chartsearchai chart search within ~25s as billing_discount / billing_refund. Serializer javadocs corrected to describe this mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up:
|
Records, in docs/adr.md (mirroring querystore's ADR format), the six decisions behind the billing-querystore integration: SPI-based integration, the optional profile-gated omod, indexed-type scope, billing_bill folding only bill-fresh fields, the two live-sync paths (AOP service events for bills, non-AOP Hibernate DB events for discounts/refunds), and the exact-SNAPSHOT version requirements. Links it from the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decision 3 recorded the selection criteria (clinician-chart lens, patient-scoped, OpenmrsData-only) but the per-resource clinical justification lived only in the serializer javadocs. Pull it into the ADR: the two hard filters + a marginal-value note; a per-type 'what it tells a clinician' for bill/discount/refund; and grouped, reasoned exclusions (not patient-scoped / facility config / policy-not-record / metadata). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
List natural-language questions a clinician can ask chart search for each of billing_bill, billing_discount, and billing_refund, marking the ones actually exercised during verification, and noting that retrieval is semantic (phrasing varies) and one question may cite multiple types. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 (structural): - BillChildDbEventListener: move the querystore bean lookups (registry/indexer/dispatcher) inside the try, so an infra lookup failure is swallowed instead of propagating out of the SaveDbEvent handler and rolling back the clinical saveBillDiscount/saveBillRefund transaction (honoring the best-effort contract the class documents). - Correct BillingQuerystoreActivator javadoc: it claimed steady-state indexing rides only on #6084 *ServiceEvents and omitted the listener — now describes both paths (Bill via SaveServiceEvent, discount/refund via the SaveDbEvent listener) and lists the bean. - Document that a PERCENTAGE billing_discount's discount_amount is a projection-time snapshot that can lag a later bill-total change until the discount is next saved (discount_percent is always current). Phase 2 (polish, from 4-agent review): - Reword the listener javadoc: drop the unsubstantiated claim that a generic-typed @eventlistener would miss Hibernate proxies (it wouldn't); keep raw SaveDbEvent<?> + instanceof, justified by mirroring querystore's own consumer and proxy-robustness. - Document the flush-thread constraint on AbstractBillChildRecordSerializer: populate() may run on the Hibernate flush thread, so it must navigate only id-loadable to-one proxies (no queries / lazy collections) to avoid a flush-inside-flush. - Source the stockmanagement require version from ${stockmanagementVersion} (matching the sibling requires and the base billing module), rather than a hardcoded 1.4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|



What
Lets the billing module contribute its patient records to the OpenMRS QueryStore so a clinician-facing semantic / keyword chart search (e.g.
chartsearchai) can retrieve them. Ships as a new, optionalbilling-querystoreomod.The key realization
The QueryStore integration path is "register a serializer," not "publish events." QueryStore's steady‑state sync (
CoreServiceEventListener) consumes the*ServiceEvents that core openmrs-core#6084 (TRUNK‑6429, merged to2.9.x) publishes for everyOpenmrsServicemethod namedsave*/void*/unvoid*/retire*/unretire*/purge*whose first arg is anOpenmrsObject, and projects any entity for which aClinicalRecordSerializeris registered.That covers
billing_bill(itsBillServiceis anOpenmrsService).BillDiscountService/BillRefundServiceare plain interfaces, so #6084's service-event advice never fires for them —billing_discount/billing_refundlive-sync instead through a smallBillChildDbEventListenerin this submodule that consumes core's other, non-AOP half: the HibernateEventInterceptor'sSaveDbEvent/DeleteDbEvent, which fire for every persisted entity. No base-billing change; all three types were verified live on a 2.9 standalone (see verification comments).Billing's services already fit that shape —
saveBill/voidBill/unvoidBill/purgeBill,saveBillDiscount,saveBillRefund— and are wired the standard OpenMRS way (TransactionProxyFactoryBean+ coreserviceInterceptors). So live indexing comes for free once a serializer is registered; the base billing services are not touched.What gets indexed — the "useful to a clinician reading the chart" lens
Three patient‑scoped resource types (all
BaseOpenmrsData, each with its ownsave*events):billing_billbilling_discountbilling_refundFolded into
billing_bill(they cascade with the bill and have no independent save events): line items, payments. Excluded (not patient‑scoped or pure config, and QueryStore only projectsOpenmrsDataanyway): timesheets, cash points, payment modes, billable‑service catalog, item prices, exemption rules, sequence models.How it's built
querystore/submodule → per type aClinicalRecordSerializer(AbstractRecordSerializersubclass), aHibernateTypeBootstrapper(backfill), and aResourceTypeProvider(discovered by QueryStore viaContext.getRegisteredComponents).e.bill.patient.uuid) and cursor one.dateCreated(they're JPA@Entitys that don't mapdateChanged).POST /ws/rest/v1/querystore/reindex {"scope":"all"}orBootstrapService), not run on startup.Why it's a separate, optional omod
QueryStore + the
#6084event API require the (currently unreleased) platform 2.9 plus an Elasticsearch/Lucene/ONNX backend. Baking that into the base module would force every billing deployment onto 2.9 + QueryStore. Instead this is a standalonebilling-querystoreomod, built only behind a Maven profile:mvn -Pquerystore clean install # → querystore/target/billing-querystore-*.omod (install alongside billing only where QueryStore runs)The default build,
pom.xml<modules>, and the basebilling.omodare unchanged and still target 2.7.8. The only edits outsidequerystore/are the profile in the rootpom.xmland a README section.Verification
querystore-api:1.0.0-SNAPSHOT+openmrs-api:2.9.0-SNAPSHOT.billing-querystore-*.omodpackages.saveBillwith no manual reindex is projected by core #6084 into abilling_billdocument and returned by achartsearchaichart search within ~25s; the bootstrapper backfill path works too. So the#6084-for-free path does hold for this module's service (theisAopProxy/ parent-child-context concern doesn't block it), and the fallback publisher aspect is not needed.Hardening (second commit)
An iterative review/polish pass tightened the slice by tracing its integration boundaries:
BillDiscountService.saveBillDiscountpersists the discount alone and does not re‑save the parent bill, so abilling_billdocument that folded in an "amount after discount" / outstanding balance would silently overstate what the patient owes until the bill's next save.billing_billnow exposes only bill‑aggregate‑derived fields that are refreshed on everysaveBill(raw total, amount paid, status); discount detail lives onbilling_discount. (saveBillRefund, by contrast, does reconcile + re‑save the bill, so refunds stay fresh.)StockItem/Drug, sostockmanagementwas added torequire_modules— transitive visibility throughbillingisn't guaranteed.patient IS NOT NULLscan). Unreachable for persisted entities (NOT‑NULL FKs), but keeps the two paths symmetric.discount_value(which conflated a percentage with a currency amount under one key) becamediscount_percent+ an always‑currencydiscount_amount;line_item_count→billed_service_count(it counts billed‑service labels, not raw line items).AbstractBillChildRecordSerializerfor the discount/refund shared logic, centralized metadata keys inBillingQueryFields, and reused the SPI'strimToNull/QueryStoreConstants.FIELD_VISIT_UUIDinstead of re‑implementing them.Verified as non‑issues along the way: the bootstrapper HQL is valid for the JPA
@Entitychildren (BaseOpenmrsDatais a@MappedSuperclass), and discount/refund status updates re‑index correctly even without adateChangedbump because querystore's upsert freshness guard is>=.Follow‑ups
billing_billvia core #6084SaveServiceEvent, andbilling_discount/billing_refundvia theSaveDbEventlistener. Elasticsearch backend not exercised (Lucene is what's deployed; the serializers are backend-agnostic).billing_billtext once we see real chart‑search queries.🤖 Generated with Claude Code